Compare commits
98 Commits
v1.5.1
...
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 |
@@ -3,7 +3,8 @@
|
||||
"allow": [
|
||||
"Bash(ffprobe -v error *)",
|
||||
"Bash(env)",
|
||||
"mcp__outline__read_document"
|
||||
"mcp__outline__read_document",
|
||||
"Bash(sh scripts/build-tui.sh)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
|
||||
@@ -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
|
||||
@@ -60,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::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` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
| `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.
|
||||
@@ -89,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 |
|
||||
|---|---|
|
||||
@@ -102,10 +108,14 @@ 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 |
|
||||
@@ -116,8 +126,8 @@ from 2026-10-11 15:15 UTC. All gauges:
|
||||
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"rate_limited"\|"error"}`, `ipx_jev_seconds_total`, `ipx_jev_paced_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended; their seconds, the wait for a turn included; and that wait alone. `malformed` is ipx's request at fault (#257); `rate_limited` is a 429 from TypeSafe, whose limits are 80 calls and 100,000 input tokens a second, which ipx paces itself under at 60 and 80,000 (#277) |
|
||||
| `ipx_jev_tokens_total{kind="input"\|"output",user,key}` | counter: tokens TypeSafe counted, as its answers say, by person and whose key paid: what Signals, icon picks and key checks cost (#275, #278). A call shared on the server's key is split by each person's share of its questions |
|
||||
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
|
||||
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
||||
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||
| `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
|
||||
|
||||
@@ -140,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
|
||||
@@ -158,8 +172,9 @@ 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.** Look at ipx's requests as spans, not traces: a trace started by a
|
||||
@@ -170,9 +185,15 @@ a count says something happened, the lines and traces say why.
|
||||
R='resource.deployment.environment.name="production" && resource.service.name="ipx"'
|
||||
$Q spans "{$R && span.http.route != \"\" && span.http.route != \"/api/events\" && span.http.route !~ \"/media/.*\" && duration > 1s} | select(span.http.route)"
|
||||
$Q spans "{$R && name=\"step\" && duration > 500ms} | select(span.of)"
|
||||
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||
$Q traces '{resource.deployment.environment.name="production" && resource.service.name=~"ipx|ipx-web|ipx-directory" && status = error}'
|
||||
$Q trace <id>
|
||||
```
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -193,9 +214,18 @@ a count says something happened, the lines and traces say why.
|
||||
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.
|
||||
@@ -213,6 +243,8 @@ 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
|
||||
@@ -228,10 +260,10 @@ 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
|
||||
@@ -242,6 +274,9 @@ a count says something happened, the lines and traces say why.
|
||||
$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
|
||||
@@ -249,6 +284,19 @@ a count says something happened, the lines and traces say why.
|
||||
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
|
||||
@@ -256,23 +304,27 @@ 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
|
||||
@@ -297,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}'
|
||||
@@ -367,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
|
||||
@@ -380,7 +465,7 @@ container after a deploy.
|
||||
|
||||
## Rules
|
||||
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
|
||||
`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.
|
||||
|
||||
@@ -409,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.
|
||||
|
||||
@@ -157,7 +157,8 @@ 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
|
||||
@@ -199,7 +200,12 @@ def main():
|
||||
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":
|
||||
spans = trace_spans(fetch(f"http://tempo:3200/api/traces/{q}"))
|
||||
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)
|
||||
@@ -209,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)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
|
||||
# checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the
|
||||
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory,
|
||||
# and both use ipx-feed. A commit to the page alone runs nothing here.
|
||||
# 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
|
||||
@@ -10,9 +10,13 @@ if ! cargo fmt --all --check >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
|
||||
pkgs="ipx-feed ipx-directory ipx"
|
||||
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"
|
||||
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
|
||||
|
||||
185
CLAUDE.md
185
CLAUDE.md
@@ -20,6 +20,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
||||
| 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`, 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,27 +69,42 @@ 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
|
||||
@@ -94,24 +119,18 @@ block page, and ipx then logged a feed as failing with `invalid peer certificate
|
||||
**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.
|
||||
@@ -123,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
|
||||
@@ -142,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
|
||||
@@ -181,13 +228,17 @@ cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
|
||||
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
|
||||
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
|
||||
cargo test # parsing, filters, retention, schedules, SQL, per-user state
|
||||
npx tsc -p . # type-checks web/src
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/contrast.js # every theme's palette against WCAG AA
|
||||
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.js # k6: many people at once against a scratch daemon with 1,500 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
|
||||
@@ -198,15 +249,18 @@ session was stopped. So:
|
||||
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
||||
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
|
||||
threads twice over, wait for it to come down before starting another.
|
||||
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
|
||||
* 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` |
|
||||
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
|
||||
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
|
||||
| `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.
|
||||
@@ -254,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
|
||||
@@ -265,6 +319,30 @@ 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.
|
||||
|
||||
@@ -313,18 +391,20 @@ ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
|
||||
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`
|
||||
@@ -342,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
|
||||
@@ -357,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
|
||||
|
||||
386
Cargo.lock
generated
386
Cargo.lock
generated
@@ -115,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"
|
||||
@@ -507,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"
|
||||
@@ -519,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"
|
||||
@@ -650,6 +674,20 @@ 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"
|
||||
@@ -679,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"
|
||||
@@ -768,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"
|
||||
@@ -994,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",
|
||||
@@ -1063,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"
|
||||
@@ -1507,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"
|
||||
@@ -1843,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"
|
||||
@@ -1860,7 +1971,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "1.5.1"
|
||||
version = "1.5.2-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
@@ -1872,6 +1983,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"ipx-directory",
|
||||
"ipx-feed",
|
||||
"ipx-play",
|
||||
"jsonwebtoken",
|
||||
"librqbit",
|
||||
"opentelemetry",
|
||||
@@ -1895,7 +2007,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipx-directory"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2-dev"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
@@ -1945,6 +2057,46 @@ dependencies = [
|
||||
"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",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipx-tui"
|
||||
version = "0.1.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -2120,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"
|
||||
@@ -2483,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"
|
||||
@@ -2504,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"
|
||||
@@ -2639,6 +2832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -2806,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"
|
||||
@@ -2921,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"
|
||||
@@ -3364,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"
|
||||
@@ -3563,6 +3865,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
@@ -4020,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"
|
||||
@@ -4298,6 +4634,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.11.0"
|
||||
@@ -4344,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"
|
||||
@@ -4496,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",
|
||||
@@ -4887,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"
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
|
||||
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
|
||||
|
||||
10
README.md
10
README.md
@@ -44,7 +44,7 @@ From source:
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/ipx daemon
|
||||
./target/release/ipxd daemon
|
||||
```
|
||||
|
||||
The first start creates **admin** with a random password, written to `admin-password` beside
|
||||
@@ -52,7 +52,7 @@ config.toml (readable by ipx's user alone; the log says where, never what it is)
|
||||
`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.
|
||||
@@ -62,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 |
|
||||
@@ -73,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,21 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx-directory"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2-dev"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn router(dir: Shared) -> Router {
|
||||
.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.
|
||||
@@ -65,6 +66,14 @@ impl From<anyhow::Error> for ApiError {
|
||||
#[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());
|
||||
@@ -92,6 +101,15 @@ pub(crate) async fn access_log(req: Request, next: Next) -> Response {
|
||||
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,
|
||||
@@ -174,6 +192,7 @@ pub(crate) async fn snapshot(
|
||||
}
|
||||
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"
|
||||
@@ -362,12 +381,78 @@ mod tests {
|
||||
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;
|
||||
|
||||
@@ -23,8 +23,10 @@ const RETRY: Duration = Duration::from_secs(3600);
|
||||
/// The top this many overall, and of each category: about a thousand shows once they overlap.
|
||||
const TOP: u32 = 100;
|
||||
const TOP_OF_EACH: u32 = 50;
|
||||
/// Apple asks for no more than about twenty calls a minute.
|
||||
const PAUSE: Duration = Duration::from_secs(3);
|
||||
/// 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) {
|
||||
@@ -46,7 +48,7 @@ pub async fn run(dir: Shared, country: String) {
|
||||
continue;
|
||||
}
|
||||
let span = tracing::info_span!("apple_sync", country = %country);
|
||||
match sync(&dir, &client, &country).instrument(span).await {
|
||||
match sync(&dir, &client, APPLE, &country).instrument(span).await {
|
||||
Ok(()) => {
|
||||
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
|
||||
}
|
||||
@@ -58,8 +60,8 @@ pub async fn run(dir: Shared, country: String) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
|
||||
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
|
||||
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() {
|
||||
@@ -74,8 +76,8 @@ async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<(
|
||||
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
|
||||
for (genre, n) in charts {
|
||||
let url = match &genre {
|
||||
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
|
||||
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
|
||||
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)),
|
||||
@@ -90,12 +92,13 @@ async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<(
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for batch in ids.chunks(100) {
|
||||
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
|
||||
let 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,
|
||||
@@ -232,6 +235,72 @@ fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
|
||||
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(
|
||||
|
||||
@@ -112,6 +112,7 @@ impl Directory {
|
||||
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
|
||||
@@ -143,6 +144,7 @@ pub async fn refresher(dir: Shared) {
|
||||
.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,
|
||||
)
|
||||
@@ -215,4 +217,49 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,15 +83,16 @@ async fn serve(path: &Path) -> Result<()> {
|
||||
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()
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! 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;
|
||||
@@ -27,8 +31,75 @@ pub fn process() -> (Option<f64>, Option<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.
|
||||
/// 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| {
|
||||
@@ -69,6 +140,20 @@ fn render(started: Instant) -> String {
|
||||
&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
|
||||
}
|
||||
|
||||
@@ -92,6 +177,52 @@ pub async fn serve(listener: 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());
|
||||
|
||||
@@ -17,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(),
|
||||
))
|
||||
}
|
||||
@@ -58,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::*;
|
||||
@@ -92,4 +204,21 @@ mod tests {
|
||||
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]');
|
||||
//
|
||||
// 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;
|
||||
navigator.clipboard.writeText(b.dataset.copy).then(() => {
|
||||
void navigator.clipboard.writeText(b.dataset.copy ?? '').then(()=>{
|
||||
b.textContent = 'Copied';
|
||||
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
|
||||
setTimeout(()=>{
|
||||
b.textContent = 'Copy';
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,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"))))
|
||||
@@ -40,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.
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,7 @@ 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
|
||||
|
||||
@@ -41,6 +41,41 @@ pub static FETCHED_BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
/// 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) {
|
||||
@@ -68,7 +103,12 @@ pub async fn fetch(
|
||||
let start = reqwest::Url::parse(feed_url).context("the feed's address")?;
|
||||
let mut url = start.clone();
|
||||
let mut permanent = true;
|
||||
for _ in 0..10 {
|
||||
let mut 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);
|
||||
@@ -100,6 +140,10 @@ pub async fn fetch(
|
||||
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
|
||||
@@ -167,6 +211,38 @@ mod tests {
|
||||
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;
|
||||
|
||||
@@ -18,9 +18,75 @@ pub use artwork::{is_image, prefer_https, site_icon};
|
||||
pub use category::{categories, category_path};
|
||||
pub use discover::{expand_input, find_feed};
|
||||
pub use failure::{Failure, explain_failure, failure_kind, is_temporary_lookup_failure};
|
||||
pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch};
|
||||
pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch, is_youtube_feed};
|
||||
pub use jev::{JEV_URL, classify};
|
||||
pub use outlines::{collect_outlines, is_opml, opml_title, parse_opml, read_opml};
|
||||
pub use parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key};
|
||||
pub use patreon::{is_patreon_creator, patreon_shows, same_feed};
|
||||
pub use privacy::{redact, redact_text, url_looks_private};
|
||||
|
||||
/// Where another service's API is (WordPress.com's, Patreon's): theirs, or in a test the stand-in
|
||||
/// `test_api` serves.
|
||||
pub(crate) fn api_base(real: &'static str) -> String {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = real;
|
||||
test_api::base()
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
real.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_api {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
/// A stand-in for WordPress.com's and Patreon's APIs, and for the feeds that send ipx to them,
|
||||
/// on a thread of its own so it outlives any one test's runtime. By path:
|
||||
/// `/challenged` answers as WordPress.com's browser check does, `/wp/v2/sites/<host>/posts`
|
||||
/// with one post, `/creator` as a Patreon creator feed of campaign 12345, and
|
||||
/// `/api/campaigns/12345` with its two shows. Anything else is a 404.
|
||||
pub(crate) fn base() -> String {
|
||||
static AT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
AT.get_or_init(|| {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let at = format!("http://{}", l.local_addr().unwrap());
|
||||
std::thread::spawn(move || {
|
||||
for mut sock in l.incoming().flatten() {
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(&sock).read_line(&mut line);
|
||||
let path = line.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
||||
let (status, body) = answer(&path);
|
||||
let _ = write!(
|
||||
sock,
|
||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
});
|
||||
at
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn answer(path: &str) -> (&'static str, String) {
|
||||
if path.starts_with("/challenged") {
|
||||
("403 Forbidden", r#"<html><script src="/__challenge/check.js"></script></html>"#.into())
|
||||
} else if path.starts_with("/wp/v2/sites/127.0.0.1/posts") {
|
||||
let post = r#"[{"date_gmt":"2026-10-09T13:34:00","guid":{"rendered":"https://blog.test/?p=1"},
|
||||
"link":"https://blog.test/hello/","title":{"rendered":"Hello"},"content":{"rendered":"<p>Hi</p>"}}]"#;
|
||||
("200 OK", post.into())
|
||||
} else if path.starts_with("/creator") {
|
||||
let feed = r#"<?xml version="1.0"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
|
||||
<atom:link href="https://www.patreon.com/rss/12345?auth=x" rel="self"/><title>Creator</title></channel></rss>"#;
|
||||
("200 OK", feed.into())
|
||||
} else if path.starts_with("/api/campaigns/12345?") {
|
||||
let shows = r#"{"data":{"attributes":{"name":"Glass Cannon"},
|
||||
"relationships":{"shows":{"data":[{"id":"7","type":"collection"},{"id":"8","type":"collection"}]}}},
|
||||
"included":[{"id":"7","type":"collection","attributes":{"title":" Main Show "}}]}"#;
|
||||
("200 OK", shows.into())
|
||||
} else {
|
||||
("404 Not Found", String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,17 @@ pub fn is_patreon_creator(url: &str) -> bool {
|
||||
matches!(patreon_parts(url), Some((_, None)))
|
||||
}
|
||||
|
||||
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
||||
/// creator's name, by number, or with no creator at all -- and the token and show are what
|
||||
/// identify it.
|
||||
/// 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 {
|
||||
// Parsing both is the slow part, and only Patreon's addresses can differ and still match.
|
||||
a == b
|
||||
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)))
|
||||
@@ -54,8 +58,8 @@ pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Optio
|
||||
let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
||||
|
||||
let api = format!(
|
||||
"https://www.patreon.com/api/campaigns/{campaign}\
|
||||
?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title"
|
||||
"{}/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() {
|
||||
@@ -102,6 +106,21 @@ 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";
|
||||
@@ -117,6 +136,13 @@ mod tests {
|
||||
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"),
|
||||
|
||||
@@ -15,7 +15,7 @@ pub(crate) fn is_wpcom_challenge(body: &[u8]) -> bool {
|
||||
/// shortcut: the API's default page of ten posts, as many as WordPress puts in a feed; a feed
|
||||
/// set to carry more gets ten here.
|
||||
pub(crate) async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
|
||||
let api = format!("https://public-api.wordpress.com/wp/v2/sites/{host}/posts");
|
||||
let api = format!("{}/wp/v2/sites/{host}/posts", crate::api_base("https://public-api.wordpress.com"));
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.timeout(fetch::FEED_TIMEOUT)
|
||||
@@ -61,6 +61,19 @@ pub(crate) fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
|
||||
#[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).
|
||||
|
||||
@@ -12,6 +12,36 @@ 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
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "1.5.1"
|
||||
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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -111,10 +112,14 @@ impl Keys {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -126,25 +131,25 @@ async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
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) };
|
||||
jsonwebtoken::encode(&h, &claims, &k).unwrap()
|
||||
}
|
||||
|
||||
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||
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",
|
||||
@@ -152,13 +157,45 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,14 +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::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
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")
|
||||
}
|
||||
@@ -107,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]
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) async fn add(
|
||||
}
|
||||
|
||||
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
|
||||
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add`
|
||||
/// 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(
|
||||
@@ -151,7 +151,7 @@ pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|u| u.is_admin)
|
||||
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --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);
|
||||
@@ -168,7 +168,7 @@ pub(crate) async fn assemble_config(db: &db::Db, mut cfg: config::Config, path:
|
||||
if config::Config::file_holds_stored(path) {
|
||||
tracing::warn!(
|
||||
"config.toml still lists feeds or server settings; they are ignored, since the \
|
||||
database holds them now. Change them in the web UI, or with ipx add and rm."
|
||||
database holds them now. Change them in the web UI, or with ipxd add and rm."
|
||||
);
|
||||
}
|
||||
stored.apply(&mut cfg);
|
||||
@@ -213,13 +213,13 @@ pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
||||
pub(crate) async fn list(ctx: &Ctx) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
if cfg.feeds.is_empty() {
|
||||
println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI.");
|
||||
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 'ipx fetch antirez' was tried instead (issue #82).
|
||||
// "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);
|
||||
@@ -406,7 +406,7 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Fe
|
||||
if cfg.feeds.contains_key(&m.id) {
|
||||
// Scanned from its config entry and still read. Dropped as derived, its stored
|
||||
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
||||
ctx.db.unmanage(&m.id).await?;
|
||||
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 {
|
||||
@@ -450,8 +450,9 @@ pub(crate) async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&confi
|
||||
// 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).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 {
|
||||
@@ -476,6 +477,22 @@ pub(crate) async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
||||
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
|
||||
@@ -499,6 +516,58 @@ 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()));
|
||||
@@ -594,6 +663,55 @@ mod tests {
|
||||
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
|
||||
@@ -613,4 +731,38 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ipx", version, about = "A headless podcatcher")]
|
||||
#[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)]
|
||||
@@ -81,7 +81,7 @@ pub(crate) enum Command {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum UserCmd {
|
||||
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray`
|
||||
/// 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
|
||||
@@ -104,14 +104,13 @@ pub(crate) enum UserCmd {
|
||||
|
||||
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
|
||||
/// listing.
|
||||
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
let read_password = || -> Result<String> {
|
||||
use std::io::Read;
|
||||
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();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
stdin.read_to_string(&mut buf)?;
|
||||
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
||||
if pw.is_empty() {
|
||||
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
|
||||
anyhow::bail!("no password on stdin: try `echo -n secret | ipxd user ...`");
|
||||
}
|
||||
Ok(pw)
|
||||
};
|
||||
@@ -139,7 +138,7 @@ pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
UserCmd::List => {
|
||||
let users = ctx.db.users().await?;
|
||||
if users.is_empty() {
|
||||
println!("no accounts yet: ipx user add <name>");
|
||||
println!("no accounts yet: ipxd user add <name>");
|
||||
}
|
||||
for u in users {
|
||||
let added = u
|
||||
@@ -232,3 +231,50 @@ pub(crate) fn duration(secs: u64) -> String {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,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>,
|
||||
@@ -52,6 +54,12 @@ 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,
|
||||
@@ -73,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 {
|
||||
@@ -190,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,
|
||||
@@ -219,6 +254,8 @@ 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![],
|
||||
@@ -359,6 +396,10 @@ pub struct Stored {
|
||||
#[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,
|
||||
@@ -373,7 +414,7 @@ fn art_cache_mb() -> u64 {
|
||||
}
|
||||
|
||||
/// `[general]` keys that live in the database once it holds the configuration.
|
||||
const STORED_KEYS: [&str; 11] = [
|
||||
const STORED_KEYS: [&str; 13] = [
|
||||
"schedule",
|
||||
"max_total_gb",
|
||||
"max_age_days",
|
||||
@@ -383,6 +424,8 @@ const STORED_KEYS: [&str; 11] = [
|
||||
"directory_url",
|
||||
"directory_token",
|
||||
"directory_share",
|
||||
"play_url",
|
||||
"play_token",
|
||||
"signals_on_server",
|
||||
"signal_days",
|
||||
];
|
||||
@@ -400,6 +443,8 @@ 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,
|
||||
}
|
||||
@@ -416,6 +461,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ pub struct Ctx {
|
||||
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
|
||||
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
|
||||
/// another add spent a minute fetching was gone once that one stored (#153).
|
||||
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
|
||||
// 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<()>,
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -23,7 +24,7 @@ pub(crate) async fn reload(ctx: &Ctx) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with
|
||||
/// 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 {
|
||||
@@ -49,54 +50,81 @@ pub(crate) async fn daemon(
|
||||
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().await?.is_empty() {
|
||||
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> | ipx user passwd admin`"
|
||||
"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().await {
|
||||
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).await {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
match ctx.db.requeue_interrupted().await {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not requeue interrupted downloads"),
|
||||
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).await {
|
||||
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);
|
||||
|
||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
||||
// 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 = {
|
||||
@@ -116,7 +144,9 @@ pub(crate) async fn daemon(
|
||||
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
|
||||
|
||||
// One command at a time: the queue is what keeps two scans from overlapping.
|
||||
tracing::info!(feeds = catalogue::subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0), "daemon started");
|
||||
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
|
||||
@@ -131,6 +161,7 @@ pub(crate) async fn daemon(
|
||||
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).
|
||||
@@ -184,7 +215,10 @@ pub(crate) async fn daemon(
|
||||
"-> {}",
|
||||
serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}"))
|
||||
);
|
||||
if !until_stopped(&ctx, &rx_stop, cli::run(&ctx, cmd)).await {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -277,7 +311,14 @@ pub(crate) async fn start_web(
|
||||
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 };
|
||||
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");
|
||||
@@ -301,7 +342,7 @@ pub(crate) async fn shutdown() {
|
||||
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
|
||||
/// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding
|
||||
/// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the
|
||||
/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a
|
||||
/// 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 {
|
||||
@@ -337,6 +378,16 @@ 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| {
|
||||
@@ -347,7 +398,7 @@ mod tests {
|
||||
let held = with("kept-in-memory");
|
||||
let ctx = test_ctx(held.clone()).await;
|
||||
ctx.db.store_config(&held).await.unwrap();
|
||||
// `ipx add` and `ipx rm`, from a process of their own (#120).
|
||||
// `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();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||
|
||||
use crate::entity::{
|
||||
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings,
|
||||
signal_verdicts, signals, subscriptions, users,
|
||||
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, push_devices, sessions,
|
||||
settings, signal_verdicts, signals, subscriptions, users,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
@@ -43,7 +43,7 @@ impl Drop for Db {
|
||||
///
|
||||
/// Not SeaORM's schema sync, which is experimental and, despite its docs, drops a unique index
|
||||
/// the entities do not describe: it dropped users_name_lower on every open, so every `ipx`
|
||||
/// command took a write lock, and the healthcheck's `ipx status` timed out behind a busy daemon.
|
||||
/// command took a write lock, and the healthcheck's `ipxd status` timed out behind a busy daemon.
|
||||
/// On Postgres it would have failed outright, dropping that index as a constraint.
|
||||
///
|
||||
/// ponytail: tables only, no columns. A column added to an existing table needs its own ALTER
|
||||
@@ -72,6 +72,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> {
|
||||
schema.create_table_from_entity(deadair_saved::Entity),
|
||||
schema.create_table_from_entity(signals::Entity),
|
||||
schema.create_table_from_entity(signal_verdicts::Entity),
|
||||
schema.create_table_from_entity(push_devices::Entity),
|
||||
] {
|
||||
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||
}
|
||||
@@ -97,7 +98,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> {
|
||||
}
|
||||
// A column added to a table that already exists, which CREATE TABLE IF NOT EXISTS leaves
|
||||
// as it was. Looked for first, and added only when it is missing: this runs on every open,
|
||||
// the healthcheck's `ipx status` included, and an ALTER takes a lock that waits behind a
|
||||
// the healthcheck's `ipxd status` included, and an ALTER takes a lock that waits behind a
|
||||
// busy daemon, which is what made the healthcheck time out once before.
|
||||
if orm.execute_unprepared("SELECT description FROM feeds LIMIT 0").await.is_err() {
|
||||
// A feed is read whole only when it has changed since the last read, so each one's
|
||||
@@ -271,11 +272,38 @@ async fn connect(location: &str, keep_open: bool) -> Result<DatabaseConnection>
|
||||
// The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to
|
||||
// open, its password exchange most of it, and a page load asked for more than were idle, so
|
||||
// the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30.
|
||||
// Not a command such as `ipx status`, nor a test, which would hold twenty for one question.
|
||||
// Not a command such as `ipxd status`, nor a test, which would hold twenty for one question.
|
||||
if keep_open && is_postgres(location) {
|
||||
opts.min_connections(20);
|
||||
}
|
||||
Database::connect(opts).await.with_context(|| format!("opening {}", redact(location)))
|
||||
let mut db = Database::connect(opts).await.with_context(|| format!("opening {}", redact(location)))?;
|
||||
// Each statement's time once it has a connection (#367), by its first word: the SQL itself
|
||||
// would be a row a query.
|
||||
db.set_metric_callback(|info| {
|
||||
let first = info.statement.sql.split_whitespace().next().unwrap_or("").to_ascii_lowercase();
|
||||
let kind =
|
||||
if ["select", "insert", "update", "delete", "with"].contains(&first.as_str()) { &first } else { "other" };
|
||||
crate::metrics::QUERIES.observe(&format!("kind=\"{kind}\""), info.elapsed.as_secs_f64());
|
||||
LAST_QUERY.set(Some(info.elapsed));
|
||||
});
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The time of the statement this thread last ran, for `waited`. SeaORM calls the metric
|
||||
/// callback in the same poll that hands the result back to `exec` and `rows`, with no await
|
||||
/// between, so the value read there is that statement's own.
|
||||
static LAST_QUERY: std::cell::Cell<Option<std::time::Duration>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Counts how long a statement waited for a connection: its whole call less its time on one
|
||||
/// (#367). Only hand-written SQL, about half of ipx's queries, comes through here; SeaORM's own
|
||||
/// queries are timed but their wait is not seen.
|
||||
/// shortcut: half the queries, measured by difference; a wrapper on sqlx's pool would see all.
|
||||
fn waited(started: std::time::Instant) {
|
||||
if let Some(q) = LAST_QUERY.take() {
|
||||
crate::metrics::DB_WAIT.observe("", started.elapsed().saturating_sub(q).as_secs_f64());
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL `connect` hands sqlx for a location: a SQLite file, created if missing, or a Postgres
|
||||
@@ -380,7 +408,7 @@ pub struct Managed {
|
||||
pub group_id: String,
|
||||
}
|
||||
|
||||
/// What `ipx list` shows next to each configured feed.
|
||||
/// What `ipxd list` shows next to each configured feed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeedSummary {
|
||||
pub title: Option<String>,
|
||||
@@ -410,7 +438,7 @@ impl Db {
|
||||
}
|
||||
let orm = connect(location, keep_open).await?;
|
||||
if !is_postgres(location) {
|
||||
// WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a
|
||||
// WAL, so the healthcheck's `ipxd status` reads while the daemon writes. It is a
|
||||
// setting of the file, kept once made, and making it takes a lock that cannot wait out
|
||||
// a busy daemon, so it is made only when the file is not already in WAL.
|
||||
let mode =
|
||||
@@ -1705,11 +1733,18 @@ impl Db {
|
||||
|
||||
/// Runs a statement; how many rows it changed.
|
||||
async fn exec(&self, sql: &str, values: Vec<sea_orm::Value>) -> Result<u64> {
|
||||
Ok(self.orm.execute_raw(self.stmt(sql, values)).await?.rows_affected())
|
||||
let (stmt, started) = (self.stmt(sql, values), std::time::Instant::now());
|
||||
// Only on success: a connection never had means no callback, and a stale time.
|
||||
let n = self.orm.execute_raw(stmt).await?.rows_affected();
|
||||
waited(started);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
async fn rows(&self, sql: &str, values: Vec<sea_orm::Value>) -> Result<Vec<sea_orm::QueryResult>> {
|
||||
Ok(self.orm.query_all_raw(self.stmt(sql, values)).await?)
|
||||
let (stmt, started) = (self.stmt(sql, values), std::time::Instant::now());
|
||||
let rows = self.orm.query_all_raw(stmt).await?;
|
||||
waited(started);
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The first admin starts subscribed to the whole catalogue: whoever wrote config.toml meant
|
||||
@@ -2254,6 +2289,51 @@ impl Db {
|
||||
Ok(Some(User::from(user)))
|
||||
}
|
||||
|
||||
/// Keeps a phone's push token for this person (#433). A token already kept for someone else
|
||||
/// is theirs no longer: the phone has signed in as this person since.
|
||||
pub async fn add_push_device(&self, user_id: i64, token: &str, sandbox: bool) -> Result<()> {
|
||||
push_devices::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
|
||||
push_devices::ActiveModel {
|
||||
token: Set(token.to_owned()),
|
||||
user_id: Set(user_id),
|
||||
sandbox: Set(sandbox),
|
||||
created: Set(now()),
|
||||
badge: Set(None),
|
||||
}
|
||||
.insert(&self.orm)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Signing out on that phone: someone else's token does nothing. Whether it was theirs.
|
||||
pub async fn remove_push_device(&self, user_id: i64, token: &str) -> Result<bool> {
|
||||
let gone = push_devices::Entity::delete_many()
|
||||
.filter(push_devices::Column::Token.eq(token))
|
||||
.filter(push_devices::Column::UserId.eq(user_id))
|
||||
.exec(&self.orm)
|
||||
.await?;
|
||||
Ok(gone.rows_affected > 0)
|
||||
}
|
||||
|
||||
/// One Apple says is no longer the app's, removed or reinstalled.
|
||||
pub async fn forget_push_device(&self, token: &str) -> Result<()> {
|
||||
push_devices::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn push_devices(&self) -> Result<Vec<push_devices::Model>> {
|
||||
Ok(push_devices::Entity::find().all(&self.orm).await?)
|
||||
}
|
||||
|
||||
pub async fn set_push_badge(&self, token: &str, n: i64) -> Result<()> {
|
||||
push_devices::Entity::update_many()
|
||||
.col_expr(push_devices::Column::Badge, Expr::val(n))
|
||||
.filter(push_devices::Column::Token.eq(token))
|
||||
.exec(&self.orm)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_session(&self, token: &str) -> Result<()> {
|
||||
sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
|
||||
Ok(())
|
||||
@@ -2420,12 +2500,23 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops treating a feed as derived, because it now has its own config entry.
|
||||
pub async fn unmanage(&self, id: &str) -> Result<()> {
|
||||
self.exec("UPDATE feeds SET managed = false WHERE id = $1", vec![id.into()]).await?;
|
||||
/// Stops treating a feed as derived, because it now has its own config entry, in `group` as
|
||||
/// that entry says. Left in its list's group, a feed of its own was hidden with the list (#358).
|
||||
pub async fn unmanage(&self, id: &str, group: Option<&str>) -> Result<()> {
|
||||
self.exec("UPDATE feeds SET managed = false, group_id = $2 WHERE id = $1", vec![id.into(), group.into()])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Feeds not derived any more that the database still puts in a group, with that group.
|
||||
pub async fn unmanaged_in_groups(&self) -> Result<Vec<(String, String)>> {
|
||||
self.rows("SELECT id, group_id FROM feeds WHERE NOT coalesce(managed, false) AND group_id IS NOT NULL", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| Ok((r.try_get("", "id")?, r.try_get("", "group_id")?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Empties a feed of its items, leaving its files alone.
|
||||
pub async fn clear_entries(&self, feed_id: &str) -> Result<()> {
|
||||
entries::Entity::delete_many().filter(entries::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
|
||||
@@ -2590,11 +2681,14 @@ impl Db {
|
||||
|
||||
/// Nothing can be in flight the moment the daemon starts, so any row still marked
|
||||
/// `downloading` is a leftover from a restart or a crash. Left alone it would sit
|
||||
/// there forever: the pending queue skips it and nothing else ever revisits it.
|
||||
pub async fn requeue_interrupted(&self) -> Result<usize> {
|
||||
Ok(self
|
||||
.exec("UPDATE enclosures SET state = 'pending' WHERE state = 'downloading' AND path IS NULL", vec![])
|
||||
.await? as usize)
|
||||
/// there forever: the pending queue skips it and nothing else ever revisits it. Gives the
|
||||
/// ones put back, for the daemon to take again at once rather than at their feed's next check.
|
||||
pub async fn requeue_interrupted(&self) -> Result<Vec<i64>> {
|
||||
const LEFT: &str = "state = 'downloading' AND path IS NULL";
|
||||
let rows = self.rows(&format!("SELECT id FROM enclosures WHERE {LEFT} ORDER BY id"), vec![]).await?;
|
||||
let ids = rows.iter().map(|r| r.try_get::<i64>("", "id")).collect::<Result<Vec<_>, _>>()?;
|
||||
self.exec(&format!("UPDATE enclosures SET state = 'pending' WHERE {LEFT}"), vec![]).await?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Puts an enclosure back in the queue so the next scan picks it up. This is how a
|
||||
@@ -3136,6 +3230,24 @@ mod tests {
|
||||
assert!(unread().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_phone_counts_for_whoever_last_signed_in_on_it() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let (me, you) =
|
||||
(db.create_user("me", None, false).await.unwrap(), db.create_user("you", None, false).await.unwrap());
|
||||
let phone = "ab".repeat(32);
|
||||
db.add_push_device(me, &phone, false).await.unwrap();
|
||||
db.set_push_badge(&phone, 4).await.unwrap();
|
||||
db.add_push_device(you, &phone, true).await.unwrap();
|
||||
let all = db.push_devices().await.unwrap();
|
||||
assert_eq!(all.len(), 1, "one phone, one row (#433)");
|
||||
assert_eq!((all[0].user_id, all[0].sandbox, all[0].badge), (you, true, None), "the new person's, sent afresh");
|
||||
|
||||
assert!(!db.remove_push_device(me, &phone).await.unwrap(), "not mine to remove any more");
|
||||
assert!(db.remove_push_device(you, &phone).await.unwrap());
|
||||
assert!(db.push_devices().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_password_signs_the_account_out_everywhere_but_keeps_its_tokens() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
@@ -4069,7 +4181,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.requeue_interrupted().await.unwrap(), 1, "only the in-flight, fileless one");
|
||||
assert_eq!(db.requeue_interrupted().await.unwrap(), [1], "only the in-flight, fileless one");
|
||||
let state = async |id: i64| db.strings_for_test(&format!("SELECT state FROM enclosures WHERE id = {id}")).await;
|
||||
assert_eq!(state(1).await, ["pending"]);
|
||||
assert_eq!(state(3).await, ["downloading"], "it has a file; leave it alone");
|
||||
|
||||
@@ -457,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,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 {
|
||||
|
||||
@@ -212,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.
|
||||
@@ -245,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"));
|
||||
@@ -307,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
|
||||
}
|
||||
@@ -326,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}"));
|
||||
@@ -353,7 +356,7 @@ 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 = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>;
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::scan::Outcome;
|
||||
use crate::{catalogue, config, feed};
|
||||
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
|
||||
@@ -31,6 +32,7 @@ pub fn opml_feeds(doc: &opml::OPML) -> Result<Vec<(String, String)>> {
|
||||
///
|
||||
/// 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
|
||||
@@ -110,6 +112,8 @@ pub(crate) const MAX_LISTED: usize = 2000;
|
||||
/// 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.
|
||||
@@ -119,13 +123,14 @@ pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(Strin
|
||||
listed.len()
|
||||
);
|
||||
let cfg = ctx.cfg();
|
||||
let existing = ctx.db.managed_feeds().await?;
|
||||
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().await?;
|
||||
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) {
|
||||
@@ -145,9 +150,21 @@ pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(Strin
|
||||
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.
|
||||
// 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()
|
||||
@@ -155,17 +172,20 @@ pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(Strin
|
||||
.map(|m| m.id.clone())
|
||||
.chain(std::iter::once(parent_id.to_string()));
|
||||
for id in list_and_feeds {
|
||||
for user in ctx.db.users().await? {
|
||||
if ctx.db.subscription(user.id, parent_id).await?.is_some() {
|
||||
ctx.db.subscribe(user.id, &id).await?;
|
||||
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) {
|
||||
@@ -187,6 +207,10 @@ pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(Strin
|
||||
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() })
|
||||
}
|
||||
|
||||
@@ -1,89 +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;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt::format::JsonFields;
|
||||
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// The JSON log line with the trace and span it belongs to (#91), so a line in Loki leads to its
|
||||
/// trace in Tempo. The JSON formatter cannot take a field of its own, so the ids go on the end of
|
||||
/// the object it writes. A line outside any traced span, or with no trace exporter, is unchanged.
|
||||
pub(crate) struct WithTrace<F>(F);
|
||||
|
||||
impl<S, N, F> FormatEvent<S, N> for WithTrace<F>
|
||||
where
|
||||
F: FormatEvent<S, N>,
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
N: for<'w> FormatFields<'w> + 'static,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &FmtContext<'_, S, N>,
|
||||
mut w: tracing_subscriber::fmt::format::Writer<'_>,
|
||||
ev: &tracing::Event<'_>,
|
||||
) -> std::fmt::Result {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let current = tracing::Span::current();
|
||||
let sc = if current.is_none() { None } else { Some(current.context().span().span_context().clone()) };
|
||||
let Some(sc) = sc.filter(|c| c.is_valid()) else {
|
||||
return self.0.format_event(ctx, w, ev);
|
||||
};
|
||||
let mut line = String::new();
|
||||
self.0.format_event(ctx, tracing_subscriber::fmt::format::Writer::new(&mut line), ev)?;
|
||||
match line.trim_end().strip_suffix('}') {
|
||||
Some(body) => writeln!(w, r#"{body},"trace_id":"{}","span_id":"{}"}}"#, sc.trace_id(), sc.span_id()),
|
||||
None => w.write_str(&line),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up logging: to stderr, as text or as JSON (`IPX_LOG_FORMAT`), into the ring the page's log
|
||||
/// reads, and to the trace exporter when there is one.
|
||||
pub(crate) fn init(otel: Option<&opentelemetry_sdk::trace::SdkTracerProvider>) {
|
||||
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;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
|
||||
// keeps debug as well, so the log view can show protocol traffic and routine
|
||||
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
|
||||
let stderr_filter = || EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into());
|
||||
// One JSON object a line for Loki (#91), with each event's fields as its own; text
|
||||
// otherwise, for someone reading a terminal.
|
||||
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
let ui_filter = EnvFilter::try_from_env("IPX_UI_LOG").unwrap_or_else(|_| "ipx=debug".into());
|
||||
tracing_subscriber::registry()
|
||||
.with((!json).then(|| {
|
||||
// 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(std::io::stderr)
|
||||
.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())
|
||||
}))
|
||||
.with(json.then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.fmt_fields(JsonFields::new())
|
||||
.event_format(WithTrace(
|
||||
tracing_subscriber::fmt::format()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_current_span(true)
|
||||
.with_span_list(false),
|
||||
))
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(stderr_filter())
|
||||
}))
|
||||
.with(logbuf::RingLayer.with_filter(ui_filter))
|
||||
.with(otel.as_ref().map(|p| {
|
||||
.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"))
|
||||
}))
|
||||
.init();
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,113 +1,5 @@
|
||||
//! ipx, the daemon and its command line. The code is in the modules below; this only starts it.
|
||||
//! `ipxd`, the daemon and its command line; everything is in the library (lib.rs).
|
||||
|
||||
mod access;
|
||||
mod art;
|
||||
mod auth;
|
||||
mod catalogue;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod ctx;
|
||||
mod daemon;
|
||||
mod db;
|
||||
mod deadair;
|
||||
mod directory;
|
||||
mod download;
|
||||
mod entity;
|
||||
// The crate root's import, so `crate::feed` reaches it from every module as before.
|
||||
use ipx_feed as feed;
|
||||
mod ipc;
|
||||
mod lists;
|
||||
mod logbuf;
|
||||
mod logging;
|
||||
mod metrics;
|
||||
mod retention;
|
||||
mod scan;
|
||||
mod signals;
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
mod torrent;
|
||||
mod web;
|
||||
|
||||
use crate::cli::{Cli, Command, UserCmd};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Command as Cmd;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
|
||||
// looking at.
|
||||
let otel = match cli.command {
|
||||
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
|
||||
_ => None,
|
||||
};
|
||||
logging::init(otel.as_ref());
|
||||
|
||||
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
|
||||
let cfg = config::Config::load(&config_path)?;
|
||||
// The daemon holds its connections open; a one-off command opens what it uses.
|
||||
let keep_open = matches!(cli.command, Command::Daemon { .. });
|
||||
let db = db::Db::open(&db::location(), keep_open).await?;
|
||||
|
||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||
let wire_cmd = match &cli.command {
|
||||
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
|
||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||
Command::Status => Some(Cmd::Status),
|
||||
Command::List
|
||||
| Command::Daemon { .. }
|
||||
| Command::User { .. }
|
||||
| Command::Add { .. }
|
||||
| Command::Rm { .. }
|
||||
| Command::Import { .. }
|
||||
| Command::Export { .. } => None,
|
||||
};
|
||||
if let Some(cmd) = &wire_cmd
|
||||
&& !cli.local
|
||||
&& ipc::daemon_is_live(&cfg.general.socket).await
|
||||
{
|
||||
return ipc::proxy(&cfg.general.socket, cmd).await;
|
||||
}
|
||||
|
||||
let socket = cfg.general.socket.clone();
|
||||
let cfg = catalogue::assemble_config(&db, cfg, &config_path).await?;
|
||||
|
||||
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||
// These write the catalogue from a process of their own, which a running daemon, holding its
|
||||
// own copy, would write over at its next change (#120).
|
||||
let changes_catalogue = matches!(
|
||||
cli.command,
|
||||
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
|
||||
);
|
||||
let (events, _) = broadcast::channel(1024);
|
||||
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
|
||||
|
||||
let result = match cli.command {
|
||||
Command::List => catalogue::list(&ctx).await,
|
||||
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events).await,
|
||||
Command::Add { url, folder, keywords, category, list } => {
|
||||
catalogue::add(&ctx, &url, folder, keywords, category, list).await
|
||||
}
|
||||
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
|
||||
Command::User { cmd } => cli::user_cmd(&ctx, cmd).await,
|
||||
Command::Import { file } => catalogue::import(&ctx, &file).await,
|
||||
Command::Export { file } => catalogue::export(&ctx, &file).await,
|
||||
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||
};
|
||||
if result.is_ok()
|
||||
&& changes_catalogue
|
||||
&& ipc::daemon_is_live(&socket).await
|
||||
&& let Err(e) = ipc::reload(&socket).await
|
||||
{
|
||||
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
|
||||
}
|
||||
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
||||
if let Some(p) = otel {
|
||||
let _ = p.shutdown();
|
||||
}
|
||||
result
|
||||
fn main() -> anyhow::Result<()> {
|
||||
ipx::main()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! 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;
|
||||
@@ -62,8 +63,23 @@ pub struct Stats {
|
||||
job: std::sync::Mutex<Option<(&'static str, Instant)>>,
|
||||
/// Jobs finished, by kind: how many, and their seconds all told.
|
||||
jobs: std::sync::Mutex<BTreeMap<&'static str, (u64, f64)>>,
|
||||
/// 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 {
|
||||
@@ -89,6 +105,46 @@ impl Stats {
|
||||
people: std::sync::Mutex::new(BTreeMap::new()),
|
||||
job: std::sync::Mutex::new(None),
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +158,12 @@ impl Stats {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -308,6 +370,42 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
||||
"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",
|
||||
@@ -429,11 +527,6 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
||||
}
|
||||
}
|
||||
|
||||
/// A label's value as Prometheus's text format reads it.
|
||||
fn label(v: &str) -> String {
|
||||
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
|
||||
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
write(out, "counter", name, help, samples);
|
||||
@@ -457,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();
|
||||
@@ -512,6 +653,28 @@ mod tests {
|
||||
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]
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -267,6 +267,142 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -171,7 +171,13 @@ pub(crate) async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope
|
||||
pub fn due_at(cfg: &config::Config, feed: &config::Feed, state: &db::HttpState, subscribed: bool) -> Option<i64> {
|
||||
let last = state.last_checked?;
|
||||
let floor = if subscribed { 0 } else { 86_400 };
|
||||
Some(last + due_after(cfg, feed, state.ttl_mins, state.error_since, state.same_since, last).max(floor) as i64)
|
||||
let wait = due_after(cfg, feed, state.ttl_mins, state.error_since, state.same_since, last).max(floor);
|
||||
// YouTube fails about 70% of reads from Tower, in streaks longer than fetch's retries; read
|
||||
// once a day, its 39 catalogue channels missed 5 days in 7 (#386). Hourly for the first day of
|
||||
// a run of failures finds a moment it answers, and a channel gone for good costs 24 requests.
|
||||
let youtube_again =
|
||||
state.error_since.is_some_and(|since| last - since < 86_400) && feed::is_youtube_feed(&feed.url);
|
||||
Some(last + if youtube_again { wait.min(3600) } else { wait } as i64)
|
||||
}
|
||||
|
||||
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
|
||||
@@ -711,6 +717,9 @@ pub(crate) async fn fetch_one(
|
||||
let mut last_pct = -1i64;
|
||||
let name = download::filename_for(url, None);
|
||||
let cfg = ctx.cfg();
|
||||
// Said while it runs, as a torrent's is: a stop mid-file leaves it 'downloading', which the
|
||||
// next start takes up at once (#410). Every way out records 'done' or 'error'.
|
||||
ctx.db.mark_enclosure(url, "downloading", None).await?;
|
||||
let got = download::download(&ctx.client, &cfg, feed_cfg, url, |done, total| {
|
||||
if let Some(t) = total.filter(|t| *t > 0) {
|
||||
let pct = (done * 100 / t) as i64;
|
||||
@@ -907,6 +916,20 @@ mod tests {
|
||||
assert_eq!(at(Some(1000), false), Some(1000 + 86_400)); // listed, nobody subscribed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_youtube_feed_is_asked_again_hourly_for_a_day() {
|
||||
let cfg = config::Config::default();
|
||||
let yt = config::Feed { url: "https://www.youtube.com/feeds/videos.xml?channel_id=UCx".into(), ..feed() };
|
||||
let at = |f: &config::Feed, since: Option<i64>, last: i64| {
|
||||
let state = db::HttpState { last_checked: Some(last), error_since: since, ..Default::default() };
|
||||
due_at(&cfg, f, &state, false).map(|t| t - last)
|
||||
};
|
||||
assert_eq!(at(&yt, None, 1000), Some(86_400), "well, it waits its day like any listed feed");
|
||||
assert_eq!(at(&yt, Some(1000), 1000 + 7200), Some(3600), "failing two hours: in an hour");
|
||||
assert_eq!(at(&yt, Some(1000), 1000 + 90_000), Some(86_400), "failing over a day: back off");
|
||||
assert_eq!(at(&feed(), Some(1000), 1000 + 7200), Some(86_400), "another host's failure is not hurried");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_long_the_same_is_checked_less_and_a_schedule_set_wins() {
|
||||
let (hour, day) = (3600u64, 86_400u64);
|
||||
@@ -1040,6 +1063,39 @@ mod tests {
|
||||
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_torrent_enclosure_goes_to_the_torrent_worker_not_the_file_downloader() {
|
||||
let body = b"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid>\
|
||||
<enclosure url=\"http://127.0.0.1:9/ep.torrent\" type=\"application/x-bittorrent\" length=\"10\"/></item></channel></rss>";
|
||||
let scan = |ctx: Arc<Ctx>| async move {
|
||||
let got = feed::Fetched::Body { bytes: body.to_vec(), etag: None, last_modified: None };
|
||||
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
|
||||
match scan_one(&ctx, "t", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), None)))
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
Outcome::Feed(s) => (s.downloaded, s.torrents),
|
||||
_ => panic!("a feed"),
|
||||
}
|
||||
};
|
||||
let state = |ctx: Arc<Ctx>| async move { ctx.db.strings_for_test("SELECT state FROM enclosures").await };
|
||||
|
||||
// Detached, as the daemon runs them: no slot free, so it waits its turn as 'downloading'.
|
||||
let mut ctx = test_ctx(config::Config::default()).await;
|
||||
ctx.detach_torrents = true;
|
||||
ctx.torrent_slots = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
let ctx = Arc::new(ctx);
|
||||
assert_eq!(scan(ctx.clone()).await, (0, 1), "counted as a torrent, not a download");
|
||||
assert_eq!(state(ctx).await, ["downloading"], "so the next scan does not queue it again");
|
||||
|
||||
// Turned off, it is skipped and still counted.
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.torrent.enabled = false;
|
||||
let ctx = Arc::new(test_ctx(cfg).await);
|
||||
assert_eq!(scan(ctx.clone()).await, (0, 1));
|
||||
assert_eq!(state(ctx).await, ["skipped"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
|
||||
let base = empty_or_feed_server().await;
|
||||
|
||||
@@ -405,6 +405,9 @@ pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
|
||||
/// 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() {
|
||||
@@ -535,8 +538,172 @@ fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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() {
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn feed() -> config::Feed {
|
||||
// Whatever `ipx add` would write, which is the shape every code path sees.
|
||||
// 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![])
|
||||
|
||||
@@ -29,6 +29,9 @@ impl Torrents {
|
||||
let opts = SessionOptions {
|
||||
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()
|
||||
};
|
||||
|
||||
@@ -184,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);
|
||||
|
||||
@@ -44,6 +44,8 @@ pub struct WebState {
|
||||
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||
pub access: Arc<crate::access::Keys>,
|
||||
pub playing: crate::deadair::Playing,
|
||||
/// Wrong passwords by address, for `login` to refuse a guesser (#409).
|
||||
pub wrong: Wrong,
|
||||
}
|
||||
|
||||
pub fn router(state: WebState) -> Router {
|
||||
@@ -51,6 +53,8 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/me", get(me).patch(patch_me))
|
||||
.route("/api/tokens", get(list_tokens).post(add_token))
|
||||
.route("/api/tokens/{id}", axum::routing::delete(remove_token))
|
||||
.route("/api/push/devices", post(add_push_device))
|
||||
.route("/api/push/devices/{token}", delete(remove_push_device))
|
||||
.route("/api/logout", post(logout))
|
||||
.route("/api/feeds", get(feeds).post(add_feed))
|
||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||
@@ -63,6 +67,7 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
|
||||
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
||||
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
||||
.route("/api/entries/{feed_id}/{guid}/share", post(share_entry))
|
||||
.route("/api/feeds/{id}/read-all", post(read_all))
|
||||
.route("/api/read-all", post(read_all_mine))
|
||||
.route("/api/feeds/{id}/download-latest", post(download_latest))
|
||||
@@ -301,13 +306,58 @@ struct Credentials {
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login(State(state): State<WebState>, Json(body): Json<Credentials>) -> Result<Response, ApiError> {
|
||||
/// Wrong passwords one address may send in `WRONG_WINDOW` (#409). Enough for someone who has
|
||||
/// forgotten which password it was; a guesser gets ten a quarter hour instead of as many as the
|
||||
/// server can check. Counted by address, not name: by name, anyone could lock anyone out.
|
||||
const WRONG_PER_ADDRESS: u32 = 10;
|
||||
const WRONG_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||
pub type Wrong = Arc<std::sync::Mutex<HashMap<String, (Instant, u32)>>>;
|
||||
|
||||
/// Who is asking: the peer, or, when the peer is a trusted proxy, the address it put last in
|
||||
/// `X-Forwarded-For`. Behind Caddy every peer is Caddy, which sets that header to the client's
|
||||
/// address ({client_ip}, Cloudflare's `Cf-Connecting-IP` for the tunnel's requests).
|
||||
fn client_addr(cfg: &crate::config::Config, peer: String, headers: &HeaderMap) -> String {
|
||||
if !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||
return peer;
|
||||
}
|
||||
let forwarded = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok());
|
||||
forwarded.and_then(|f| f.rsplit(',').next()).map(str::trim).filter(|a| !a.is_empty()).map_or(peer, str::to_owned)
|
||||
}
|
||||
|
||||
/// How long `from` has still to wait, if it has used up its wrong passwords.
|
||||
fn wrong_wait(wrong: &HashMap<String, (Instant, u32)>, from: &str, now: Instant) -> Option<Duration> {
|
||||
let (start, n) = wrong.get(from)?;
|
||||
let gone = now.duration_since(*start);
|
||||
(*n >= WRONG_PER_ADDRESS && gone < WRONG_WINDOW).then(|| WRONG_WINDOW - gone)
|
||||
}
|
||||
|
||||
/// Counts one against `from`'s window, which starts at its first wrong password in it.
|
||||
fn count_wrong(wrong: &mut HashMap<String, (Instant, u32)>, from: &str, now: Instant) {
|
||||
// Windows past their end are forgotten, or a flood from many addresses would be kept forever.
|
||||
wrong.retain(|_, (start, _)| now.duration_since(*start) < WRONG_WINDOW);
|
||||
wrong.entry(from.to_owned()).or_insert((now, 0)).1 += 1;
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<WebState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Credentials>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let from = client_addr(&state.ctx.cfg(), peer.ip().to_string(), &headers);
|
||||
let wait = wrong_wait(&state.wrong.lock().expect("a panic elsewhere poisoned this lock"), &from, Instant::now());
|
||||
if let Some(wait) = wait {
|
||||
tracing::warn!(%from, "sign-in refused: too many wrong passwords");
|
||||
let minutes = wait.as_secs() / 60 + 1;
|
||||
return Err(ApiError::too_many(format!("too many wrong passwords; try again in {minutes} minutes")));
|
||||
}
|
||||
let name = body.name.trim().to_ascii_lowercase();
|
||||
let user = state.ctx.db.user_by_name(&name).await?;
|
||||
// The same answer either way, in the same time: whether a name exists is not something to leak.
|
||||
let ok = crate::auth::check_password(body.password, user.as_ref().and_then(|u| u.pass_hash.clone())).await;
|
||||
if !ok {
|
||||
tracing::warn!(user = %name, "failed sign-in");
|
||||
count_wrong(&mut state.wrong.lock().expect("a panic elsewhere poisoned this lock"), &from, Instant::now());
|
||||
tracing::warn!(user = %name, %from, "failed sign-in");
|
||||
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
|
||||
}
|
||||
|
||||
@@ -622,7 +672,7 @@ fn require_admin(user: &crate::db::User) -> Result<(), ApiError> {
|
||||
}
|
||||
|
||||
/// Demoting or removing this account would leave nobody able to manage anyone, and the only
|
||||
/// way back would be `ipx user` on the box.
|
||||
/// way back would be `ipxd user` on the box.
|
||||
fn last_admin(users: &[crate::db::User], id: i64) -> bool {
|
||||
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
|
||||
admins == [id]
|
||||
@@ -665,6 +715,39 @@ async fn add_token(
|
||||
Ok(Json(serde_json::json!({ "id": id, "name": name, "token": token })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushDevice {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
sandbox: bool,
|
||||
}
|
||||
|
||||
/// The iOS app's phone, to be sent this person's unread count for its icon (#433). Sent by the
|
||||
/// page on the app's behalf, as the person signed in there.
|
||||
async fn add_push_device(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<PushDevice>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let token = body.token.to_ascii_lowercase();
|
||||
if !crate::push::valid_token(&token) {
|
||||
return Err(ApiError::bad_request("that is not a device token from Apple"));
|
||||
}
|
||||
state.ctx.db.add_push_device(user.id, &token, body.sandbox).await?;
|
||||
tracing::info!(user = %user.name, sandbox = body.sandbox, "push: a phone was registered");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Signing out in the app: that phone's icon is no longer this person's to count.
|
||||
async fn remove_push_device(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state.ctx.db.remove_push_device(user.id, &token.to_ascii_lowercase()).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn remove_token(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
@@ -716,7 +799,7 @@ async fn add_user(
|
||||
if state.ctx.db.user_by_name(&name).await?.is_some() {
|
||||
return Err(ApiError::bad_request(format!("{name} already exists")));
|
||||
}
|
||||
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
||||
// No password is someone the proxy signs in, as with `ipxd user add --no-password`.
|
||||
let hash = if body.password.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -1238,7 +1321,7 @@ fn check_url(url: &str, id: &str, feeds: &BTreeMap<String, crate::config::Feed>)
|
||||
Ok(u) => return Err(format!("{:?} is not an http(s) URL", u.scheme())),
|
||||
Err(e) => return Err(format!("that is not a valid URL: {e}")),
|
||||
}
|
||||
if let Some((other, _)) = feeds.iter().find(|(k, f)| k.as_str() != id && f.url == url) {
|
||||
if let Some((other, _)) = feeds.iter().find(|(k, f)| k.as_str() != id && crate::feed::same_feed(&f.url, url)) {
|
||||
return Err(format!("{other:?} is already subscribed to that URL"));
|
||||
}
|
||||
Ok(url.to_owned())
|
||||
@@ -1293,6 +1376,336 @@ impl IntoResponse for ApiError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::access::tests as access;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// The router as the daemon serves it, on a scratch database, with the test signer's keys.
|
||||
async fn app(cfg: crate::config::Config) -> (Router, Arc<Ctx>) {
|
||||
let ctx = Arc::new(crate::test_util::test_ctx(cfg).await);
|
||||
let state = WebState {
|
||||
ctx: ctx.clone(),
|
||||
cmds: mpsc::channel(8).0,
|
||||
events: broadcast::channel(8).0,
|
||||
access: Arc::new(access::keys()),
|
||||
playing: Default::default(),
|
||||
wrong: Default::default(),
|
||||
};
|
||||
(router(state), ctx)
|
||||
}
|
||||
|
||||
/// A request from `from`, as the listener would see its peer; the status, Set-Cookie and body.
|
||||
async fn ask(
|
||||
app: &Router,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
from: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: Option<serde_json::Value>,
|
||||
) -> (u16, String, String) {
|
||||
let mut req = axum::http::Request::builder().method(method).uri(uri);
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, *v);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header("content-type", "application/json").body(axum::body::Body::from(b.to_string())),
|
||||
None => req.body(axum::body::Body::empty()),
|
||||
};
|
||||
let mut req = req.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(SocketAddr::new(from.parse().unwrap(), 40000)));
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let cookie = resp.headers().get(header::SET_COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, cookie, String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
const CADDY: &str = "192.168.64.10";
|
||||
|
||||
fn proxied() -> crate::config::Config {
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.web.trusted_header = "Cf-Access-Authenticated-User-Email".into();
|
||||
cfg.web.trusted_proxies = vec![CADDY.into()];
|
||||
cfg.web.auto_create_users = true;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_name_is_taken_from_the_proxys_header_only_from_the_proxy() {
|
||||
let (app, _) = app(proxied()).await;
|
||||
let who = [("Cf-Access-Authenticated-User-Email", "Rays@SDF1.net")];
|
||||
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &who, None).await;
|
||||
assert_eq!(s, 200, "{me}");
|
||||
let me: serde_json::Value = serde_json::from_str(&me).unwrap();
|
||||
assert_eq!((me["name"].as_str(), me["admin"].as_bool()), (Some("rays@sdf1.net"), Some(true)), "made, first");
|
||||
|
||||
// Anyone else able to reach the port could send the header and be whoever they liked.
|
||||
assert_eq!(ask(&app, "GET", "/api/me", "192.168.64.1", &who, None).await.0, 401, "cloudflared itself");
|
||||
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &who, None).await.0, 401);
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &[], None).await.0, 401, "from the proxy, vouching for nobody");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_access_set_the_name_is_the_signed_tokens_and_nothing_else() {
|
||||
let mut cfg = proxied();
|
||||
cfg.web.access_team = access::TEAM.into();
|
||||
cfg.web.access_aud = access::AUD.into();
|
||||
let (app, _) = app(cfg).await;
|
||||
let rs = jsonwebtoken::Algorithm::RS256;
|
||||
let good = access::token(access::SIGNER, rs, access::claims(access::AUD, 300));
|
||||
fn with(t: &str) -> [(&str, &str); 2] {
|
||||
[("Cf-Access-Authenticated-User-Email", "someone-else@x.test"), ("Cf-Access-Jwt-Assertion", t)]
|
||||
}
|
||||
|
||||
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &with(&good), None).await;
|
||||
assert_eq!(s, 200, "{me}");
|
||||
assert!(me.contains(r#""name":"rays@sdf1.net""#), "the token's email, not the header's: {me}");
|
||||
|
||||
let other_app = access::token(access::SIGNER, rs, access::claims("another-app", 300));
|
||||
let expired = access::token(access::SIGNER, rs, access::claims(access::AUD, -3600));
|
||||
for (t, why) in
|
||||
[(other_app.as_str(), "another application's"), (expired.as_str(), "expired"), ("x.y.z", "garbage")]
|
||||
{
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &with(t), None).await.0, 401, "{why}");
|
||||
}
|
||||
let header_only = [("Cf-Access-Authenticated-User-Email", "rays@sdf1.net")];
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &header_only, None).await.0, 401, "no token");
|
||||
let token_only = [("Cf-Access-Jwt-Assertion", good.as_str())];
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &token_only, None).await.0, 401, "no header to switch it on");
|
||||
assert_eq!(
|
||||
ask(&app, "GET", "/api/me", "10.0.0.5", &with(&good), None).await.0,
|
||||
401,
|
||||
"a good token, not via Caddy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_is_refused_and_the_right_one_after_it_signs_in() {
|
||||
let (app, ctx) = app(crate::config::Config::default()).await;
|
||||
let hash = crate::auth::hash_password("correct horse").unwrap();
|
||||
ctx.db.create_user("dee", Some(&hash), false).await.unwrap();
|
||||
let login = |pw: &str| serde_json::json!({ "name": "Dee", "password": pw });
|
||||
for _ in 0..3 {
|
||||
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("wrong"))).await.0, 401);
|
||||
}
|
||||
let (s, _, _) = ask(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/login",
|
||||
"10.0.0.5",
|
||||
&[],
|
||||
Some(serde_json::json!({ "name": "nobody", "password": "x" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, 401, "an unknown name has the same answer");
|
||||
let (s, cookie, _) = ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("correct horse"))).await;
|
||||
assert_eq!(s, 200);
|
||||
let session = cookie.split(';').next().unwrap();
|
||||
assert!(session.starts_with("ipx_session=") && cookie.contains("HttpOnly"), "{cookie}");
|
||||
let (s, _, me) = ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", session)], None).await;
|
||||
assert!(s == 200 && me.contains(r#""name":"dee""#), "{s} {me}");
|
||||
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", "ipx_session=forged")], None).await.0, 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ten_wrong_passwords_from_one_address_hold_it_off_and_no_other() {
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.web.trusted_proxies = vec![CADDY.into()];
|
||||
let (app, ctx) = app(cfg).await;
|
||||
let hash = crate::auth::hash_password("correct horse").unwrap();
|
||||
ctx.db.create_user("dee", Some(&hash), false).await.unwrap();
|
||||
let login = |pw: &str| Some(serde_json::json!({ "name": "dee", "password": pw }));
|
||||
let via_caddy = |client| [("x-forwarded-for", client)];
|
||||
for _ in 0..10 {
|
||||
let (s, _, _) = ask(&app, "POST", "/api/login", CADDY, &via_caddy("203.0.113.7"), login("wrong")).await;
|
||||
assert_eq!(s, 401);
|
||||
}
|
||||
let (s, _, body) =
|
||||
ask(&app, "POST", "/api/login", CADDY, &via_caddy("203.0.113.7"), login("correct horse")).await;
|
||||
assert!(s == 429 && body.contains("try again in 15 minutes"), "{s} {body}");
|
||||
// Someone else behind the same Caddy is not held off with them, nor is Dee from elsewhere.
|
||||
let (s, _, _) =
|
||||
ask(&app, "POST", "/api/login", CADDY, &via_caddy("198.51.100.2"), login("correct horse")).await;
|
||||
assert_eq!(s, 200);
|
||||
// Only Caddy's word is taken for the address: anyone else sending the header is counted as
|
||||
// themselves, and cannot step out of a window by naming a new address each time.
|
||||
for n in 0..10 {
|
||||
let hdr = [("x-forwarded-for", format!("192.0.2.{n}"))];
|
||||
let hdr: Vec<(&str, &str)> = hdr.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.9", &hdr, login("wrong")).await.0, 401);
|
||||
}
|
||||
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.9", &[], login("correct horse")).await.0, 429);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_place_saved_in_deadairs_clock_is_read_in_it_without_the_playlist() {
|
||||
let (app, ctx) = app(crate::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 entries (feed_id, guid, first_seen, duration) VALUES ('f', 'h', 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)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (8, 'f', 'h', 'http://x/b.mp3', '/x/b.mp3', 'done', 100)",
|
||||
] {
|
||||
db.sql(sql).await.unwrap();
|
||||
}
|
||||
// Two minutes cut, both before 3:20 of the shortened version.
|
||||
let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []}}"#;
|
||||
db.store_plans(7, plans).await.unwrap();
|
||||
db.store_plans(8, plans).await.unwrap();
|
||||
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||
db.create_session(u, "s1").await.unwrap();
|
||||
let ctx = &ctx;
|
||||
let place = |guid: &'static str| async move {
|
||||
let order = crate::db::order_sql("published", "desc");
|
||||
let rows = ctx.db.entries_in(u, Some("f"), crate::db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||
rows.into_iter().find(|r| r.guid == guid).unwrap().position
|
||||
};
|
||||
let save = |guid: &str, body: serde_json::Value| {
|
||||
let uri = format!("/api/entries/f/{guid}/position");
|
||||
let app = app.clone();
|
||||
async move { ask(&app, "POST", &uri, "10.0.0.5", &[("cookie", "ipx_session=s1")], Some(body)).await.0 }
|
||||
};
|
||||
// Naming another item's file is taken as no file: the place stays as sent.
|
||||
assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 8 })).await, 204);
|
||||
assert_eq!(place("g").await, 200);
|
||||
// From a copy kept on the device, no playlist asked for: 3:20 of it is 5:20 of the original.
|
||||
assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 7 })).await, 204);
|
||||
assert_eq!(place("g").await, 320);
|
||||
// The original's own clock, from a player that says nothing of DeadAir, is left as it is.
|
||||
assert_eq!(save("h", serde_json::json!({ "secs": 200 })).await, 204);
|
||||
assert_eq!(place("h").await, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_of_wrong_passwords_ends_and_is_forgotten() {
|
||||
let mut wrong = HashMap::new();
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..WRONG_PER_ADDRESS {
|
||||
count_wrong(&mut wrong, "a", t0);
|
||||
}
|
||||
assert!(wrong_wait(&wrong, "a", t0).is_some());
|
||||
assert_eq!(wrong_wait(&wrong, "a", t0 + WRONG_WINDOW), None);
|
||||
count_wrong(&mut wrong, "b", t0 + WRONG_WINDOW);
|
||||
assert!(!wrong.contains_key("a"), "a past window is dropped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_signal_is_made_changed_listed_and_removed_by_its_owner_alone() {
|
||||
let (app, ctx) = app(proxied()).await;
|
||||
// As the proxy signs each in; the name is all that changes.
|
||||
let call = |who: &'static str, m: &'static str, uri: String, b: Option<serde_json::Value>| {
|
||||
let app = app.clone();
|
||||
async move { ask(&app, m, &uri, CADDY, &[("Cf-Access-Authenticated-User-Email", who)], b).await }
|
||||
};
|
||||
let (dee, sam) = ("dee@x.test", "sam@x.test");
|
||||
let json = |b: &str| serde_json::from_str::<serde_json::Value>(b).unwrap();
|
||||
|
||||
let make = serde_json::json!({ "name": "Rust", "keywords": ["rust", " Rust ", "cargo"], "icon": "solid/gear" });
|
||||
let (s, _, b) = call(dee, "POST", "/api/signals".into(), Some(make)).await;
|
||||
assert_eq!(s, 200, "{b}");
|
||||
let id = json(&b)["id"].as_i64().unwrap();
|
||||
for (bad, why) in [
|
||||
(serde_json::json!({ "name": "", "keywords": ["x"] }), "no name"),
|
||||
(serde_json::json!({ "name": "X", "keywords": [] }), "no words"),
|
||||
(serde_json::json!({ "name": "X", "keywords": ["x"], "icon": "solid/../x" }), "an icon that is not a name"),
|
||||
(
|
||||
serde_json::json!({ "name": "X", "keywords": ["x"], "description": "d".repeat(501) }),
|
||||
"a long description",
|
||||
),
|
||||
] {
|
||||
assert_eq!(call(dee, "POST", "/api/signals".into(), Some(bad)).await.0, 400, "{why}");
|
||||
}
|
||||
let (_, _, list) = call(dee, "GET", "/api/signals".into(), None).await;
|
||||
let list = json(&list);
|
||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||
assert_eq!(list[0]["keywords"], serde_json::json!(["rust", "cargo"]), "trimmed, once each");
|
||||
assert_eq!(list[0]["icon"], "solid/gear");
|
||||
|
||||
let change =
|
||||
serde_json::json!({ "name": "Rust lang", "keywords": ["rustlang"], "description": "the language" });
|
||||
assert_eq!(call(dee, "PATCH", format!("/api/signals/{id}"), Some(change)).await.0, 204);
|
||||
let row =
|
||||
ctx.db.signal(ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap().id, id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
(row.name.as_str(), row.keywords.as_slice(), row.description.as_deref()),
|
||||
("Rust lang", &["rustlang".to_owned()][..], Some("the language"))
|
||||
);
|
||||
assert_eq!(
|
||||
call(dee, "PATCH", format!("/api/signals/{id}"), Some(serde_json::json!({ "icon": null }))).await.0,
|
||||
204
|
||||
);
|
||||
assert!(
|
||||
ctx.db.signal(row_owner(&ctx, "dee@x.test").await, id).await.unwrap().unwrap().icon.is_none(),
|
||||
"cleared"
|
||||
);
|
||||
|
||||
// What it picked out is listed under it.
|
||||
let owner = row_owner(&ctx, "dee@x.test").await;
|
||||
ctx.db
|
||||
.exec_for_test(&format!(
|
||||
"INSERT INTO subscriptions (user_id, feed_id) VALUES ({owner}, 'f');
|
||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('f', 'g1', 'Rust 2.0', 1), ('f', 'g2', 'Gardening', 1);"
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
ctx.db.record_verdicts("f", "g1", &[(id, true)]).await.unwrap();
|
||||
ctx.db.record_verdicts("f", "g2", &[(id, false)]).await.unwrap();
|
||||
let (s, _, page) = call(dee, "GET", format!("/api/signals/{id}/entries"), None).await;
|
||||
let page = json(&page);
|
||||
assert_eq!((s, page["total"].as_i64()), (200, Some(1)), "{page}");
|
||||
assert_eq!(page["entries"][0]["guid"], "g1");
|
||||
|
||||
// Someone else's Signal is not there for them, in any way.
|
||||
assert_eq!(call(sam, "GET", format!("/api/signals/{id}/entries"), None).await.0, 404);
|
||||
assert_eq!(
|
||||
call(sam, "PATCH", format!("/api/signals/{id}"), Some(serde_json::json!({ "name": "Mine" }))).await.0,
|
||||
404
|
||||
);
|
||||
assert_eq!(call(sam, "DELETE", format!("/api/signals/{id}"), None).await.0, 404);
|
||||
let (_, _, theirs) = call(sam, "GET", "/api/signals".into(), None).await;
|
||||
assert_eq!(json(&theirs), serde_json::json!([]));
|
||||
|
||||
assert_eq!(call(dee, "DELETE", format!("/api/signals/{id}"), None).await.0, 204);
|
||||
assert_eq!(call(dee, "DELETE", format!("/api/signals/{id}"), None).await.0, 404, "already gone");
|
||||
assert!(
|
||||
ctx.db
|
||||
.strings_for_test(&format!("SELECT guid FROM signal_verdicts WHERE signal_id = {id}"))
|
||||
.await
|
||||
.is_empty(),
|
||||
"its verdicts with it"
|
||||
);
|
||||
}
|
||||
|
||||
async fn row_owner(ctx: &Ctx, name: &str) -> i64 {
|
||||
ctx.db.user_by_name(name).await.unwrap().unwrap().id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_block_list_and_the_typesafe_key_are_checked_before_they_are_saved() {
|
||||
let (app, ctx) = app(proxied()).await;
|
||||
let who = [("Cf-Access-Authenticated-User-Email", "dee@x.test")];
|
||||
let patch = |b: serde_json::Value| ask(&app, "PATCH", "/api/me", CADDY, &who, Some(b));
|
||||
|
||||
let many: Vec<String> = (0..501).map(|i| format!("w{i}")).collect();
|
||||
assert_eq!(patch(serde_json::json!({ "blocked": many })).await.0, 400, "501 words");
|
||||
assert_eq!(patch(serde_json::json!({ "blocked": ["x".repeat(201)] })).await.0, 400, "a 201-character word");
|
||||
assert_eq!(patch(serde_json::json!({ "blocked": [" Spoiler ", "spoiler", "", "ads"] })).await.0, 204);
|
||||
let me = ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap();
|
||||
assert_eq!(ctx.db.blocklist(me.id, "").await.unwrap(), ["Spoiler", "ads"], "trimmed, once each");
|
||||
|
||||
// Refused here, before TypeSafe is asked anything.
|
||||
for bad in ["has a space", "tab\there", &"k".repeat(501)] {
|
||||
assert_eq!(patch(serde_json::json!({ "typesafe_key": bad })).await.0, 400, "{bad}");
|
||||
}
|
||||
assert!(!ctx.db.typesafe_key_set(me.id).await.unwrap());
|
||||
assert_eq!(patch(serde_json::json!({ "typesafe_key": null })).await.0, 204, "forgetting needs no check");
|
||||
assert_eq!(patch(serde_json::json!({ "typesafe_key": " " })).await.0, 204, "nor does an empty one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_picks_are_counted_by_the_hour_and_the_person() {
|
||||
let mut picks = std::collections::HashMap::new();
|
||||
@@ -1410,10 +1823,38 @@ mod tests {
|
||||
deadair: None,
|
||||
ms: Some(5),
|
||||
filter: Some("unread".into()),
|
||||
trace: None,
|
||||
span: None,
|
||||
start: None,
|
||||
state: Some("3/50 of 120".into()),
|
||||
};
|
||||
for ev in ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown"] {
|
||||
for ev in
|
||||
["page_error", "rejection", "player_error", "play_start", "stall", "list_shown", "swipe", "scroll_stuck"]
|
||||
{
|
||||
assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}");
|
||||
}
|
||||
// A tap's span takes a trace's ids and a time the server can believe (#376).
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
|
||||
let tap = |trace: &str, start: u64| ClientEvent {
|
||||
trace: Some(trace.into()),
|
||||
span: Some("00f067aa0ba902b7".into()),
|
||||
start: Some(start),
|
||||
msg: Some("pointerdown".into()),
|
||||
..e("tap")
|
||||
};
|
||||
let good = "4bf92f3577b34da6a3ce929d0e0e4736";
|
||||
assert_eq!(client_event(u.clone(), Json(tap(good, now - 2000))).await, StatusCode::NO_CONTENT);
|
||||
assert_eq!(client_event(u.clone(), Json(tap("not hex", now))).await, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(client_event(u.clone(), Json(tap(&"0".repeat(32), now))).await, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
client_event(u.clone(), Json(tap(good, now - 7_200_000))).await,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"hours ago"
|
||||
);
|
||||
assert_eq!(
|
||||
client_event(u.clone(), Json(ClientEvent { trace: None, ..tap(good, now) })).await,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(client_event(u, Json(e("anything"))).await, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
@@ -1637,6 +2078,43 @@ async fn one_entry(
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShareBody {
|
||||
/// The file to share; the first sound file when left out.
|
||||
enc: Option<i64>,
|
||||
}
|
||||
|
||||
/// A link to the episode's page on ipx-play (#447), or none, with why, when the page should
|
||||
/// share the episode's own address as before: a private feed, a post, no ipx-play set up.
|
||||
#[derive(Serialize)]
|
||||
struct ShareLink {
|
||||
url: Option<String>,
|
||||
why: Option<&'static str>,
|
||||
}
|
||||
|
||||
async fn share_entry(
|
||||
State(state): State<WebState>,
|
||||
Path((feed_id, guid)): Path<(String, String)>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<ShareBody>,
|
||||
) -> Result<Json<ShareLink>, ApiError> {
|
||||
subscribed(&state, &user, &feed_id).await?;
|
||||
use crate::play::NoLink;
|
||||
let why = match crate::play::share(&state.ctx, user.id, &feed_id, &guid, body.enc).await {
|
||||
Ok(s) => return Ok(Json(ShareLink { url: Some(s.url), why: None })),
|
||||
Err(NoLink::NotFound) => return Err(ApiError::not_found("no such entry")),
|
||||
Err(NoLink::NotSetUp) => "not_set_up",
|
||||
Err(NoLink::Private) => "private",
|
||||
Err(NoLink::NoFile) => "no_file",
|
||||
Err(NoLink::Failed(e)) => {
|
||||
// The person still gets the episode's own address; this is for whoever runs the server.
|
||||
tracing::warn!(error = %e, "sharing an episode through ipx-play failed");
|
||||
"failed"
|
||||
}
|
||||
};
|
||||
Ok(Json(ShareLink { url: None, why: Some(why) }))
|
||||
}
|
||||
|
||||
/// An item's text as the page may show it: the feed's HTML cleaned, and its Markdown, where it
|
||||
/// sent one, made HTML and cleaned the same way (#285).
|
||||
fn clean_row(sanitizer: &mut ammonia::Builder<'static>, row: &mut crate::db::EntryRow) {
|
||||
@@ -1669,7 +2147,9 @@ fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<
|
||||
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
|
||||
None => ammonia::UrlRelative::PassThrough,
|
||||
});
|
||||
sanitizer.clean(html).to_string()
|
||||
let clean = sanitizer.clean(html).to_string();
|
||||
crate::art::name_in_text(&clean);
|
||||
clean
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1720,6 +2200,9 @@ async fn add_feed(
|
||||
/// `None` scans whatever is due, which a feed never checked always is. The add has
|
||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||
// A feed the server already has brings no new items to wake the Signals judge, and its recent
|
||||
// ones waited up to five minutes for the next pass to reach the new subscriber's Signals (#415).
|
||||
state.ctx.signals.notify_one();
|
||||
let force = feed.is_some();
|
||||
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
|
||||
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||
@@ -1884,7 +2367,7 @@ async fn patch_feed(
|
||||
let found =
|
||||
subs.iter().find(|s| s.id == id).ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
||||
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
||||
state.ctx.db.unmanage(&id).await?;
|
||||
state.ctx.db.unmanage(&id, found.cfg.group.as_deref()).await?;
|
||||
}
|
||||
|
||||
let checked = match &body.url {
|
||||
@@ -2271,7 +2754,8 @@ struct ArtQuery {
|
||||
u: String,
|
||||
}
|
||||
|
||||
/// Artwork, from iPX: the page asks here for every image a feed or item names, and the first
|
||||
/// Artwork, from iPX: the page asks here for every image a feed or item names (and, to keep for
|
||||
/// offline, the pictures in item text, #389), and the first
|
||||
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
|
||||
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
|
||||
/// server. Only an address some feed or item names as its artwork, and only an image up to
|
||||
@@ -2291,9 +2775,10 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
|
||||
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return art_response(kind, body);
|
||||
}
|
||||
// Only what a feed here or the directory names: anything else would make ipx fetch any
|
||||
// Only what a feed here or the directory names: anything else would make ipxd fetch any
|
||||
// address it is given. The Directory shows podcasts nobody here subscribes to (#273).
|
||||
if !crate::directory::listed_art(&q.u)
|
||||
&& !crate::art::named_in_text(&q.u)
|
||||
&& !state.ctx.db.names_image(&q.u).instrument(db::step("names_image")).await.unwrap_or(false)
|
||||
{
|
||||
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
|
||||
@@ -2420,6 +2905,10 @@ struct Position {
|
||||
secs: i64,
|
||||
/// The length the player measured, for an episode whose feed gives none.
|
||||
duration: Option<i64>,
|
||||
/// The file, when the player was playing it without its dead air: its places are in that
|
||||
/// clock. The playlist tells the server so too, but a copy kept on the device (#412) plays
|
||||
/// without one, and the server forgets what it was told when it restarts.
|
||||
deadair: Option<i64>,
|
||||
}
|
||||
|
||||
async fn set_position(
|
||||
@@ -2428,6 +2917,17 @@ async fn set_position(
|
||||
user: crate::db::User,
|
||||
Json(body): Json<Position>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let key = (user.id, feed_id.clone(), guid.clone());
|
||||
if let Some(id) = body.deadair
|
||||
&& !state.playing.lock().expect("a panic elsewhere poisoned this lock").contains_key(&key)
|
||||
&& let Some(enc) = state.ctx.db.enclosure(id).await?
|
||||
// Only this item's file: another's plan would put the place somewhere else.
|
||||
&& enc.feed_id == feed_id
|
||||
&& enc.guid == guid
|
||||
&& let Some(plan) = crate::deadair::plan_for(&state.ctx, user.id, &feed_id, id).await?
|
||||
{
|
||||
state.playing.lock().expect("a panic elsewhere poisoned this lock").insert(key, Arc::new(plan));
|
||||
}
|
||||
let (secs, duration) =
|
||||
crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?;
|
||||
state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?;
|
||||
@@ -2546,7 +3046,7 @@ async fn import_opml(
|
||||
Ok(Json(serde_json::json!({ "added": added, "already": already })))
|
||||
}
|
||||
|
||||
/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's
|
||||
/// The numbers `ipxd status` prints, and the version, for a dashboard such as Homepage's
|
||||
/// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token.
|
||||
async fn status(State(state): State<WebState>) -> Response {
|
||||
match crate::daemon::status(&state.ctx).await {
|
||||
@@ -2576,6 +3076,9 @@ struct Settings {
|
||||
/// Whether a token is set: the token itself never leaves the server.
|
||||
directory_token_set: bool,
|
||||
directory_share: bool,
|
||||
play_url: String,
|
||||
/// Whether a token is set, as for the directory's.
|
||||
play_token_set: bool,
|
||||
signals_on_server: bool,
|
||||
signal_days: u64,
|
||||
/// Whether the server has a TypeSafe key (`TYPESAFE_KEY`) for `signals_on_server` to use.
|
||||
@@ -2596,6 +3099,8 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||
directory_url: cfg.general.directory_url.clone(),
|
||||
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
|
||||
directory_share: cfg.general.directory_share,
|
||||
play_url: cfg.general.play_url.clone(),
|
||||
play_token_set: !cfg.general.play_token.trim().is_empty(),
|
||||
signals_on_server: cfg.general.signals_on_server,
|
||||
signal_days: cfg.general.signal_days,
|
||||
typesafe_server_key: crate::signals::typesafe_key().is_some(),
|
||||
@@ -2615,6 +3120,10 @@ struct SettingsPatch {
|
||||
/// Replaces the token; empty clears it.
|
||||
directory_token: Option<String>,
|
||||
directory_share: Option<bool>,
|
||||
/// Empty for none.
|
||||
play_url: Option<String>,
|
||||
/// Replaces the token; empty clears it.
|
||||
play_token: Option<String>,
|
||||
signals_on_server: Option<bool>,
|
||||
/// 1 to 365.
|
||||
signal_days: Option<u64>,
|
||||
@@ -2665,6 +3174,16 @@ async fn patch_settings(
|
||||
if let Some(v) = body.directory_token {
|
||||
cfg.general.directory_token = v.trim().to_owned();
|
||||
}
|
||||
if let Some(v) = body.play_url {
|
||||
let v = v.trim().trim_end_matches('/').to_owned();
|
||||
if !v.is_empty() && !url::Url::parse(&v).is_ok_and(|u| matches!(u.scheme(), "http" | "https")) {
|
||||
return Err(ApiError::bad_request(format!("{v:?} is not a web address; try https://play.ipxreader.app")));
|
||||
}
|
||||
cfg.general.play_url = v;
|
||||
}
|
||||
if let Some(v) = body.play_token {
|
||||
cfg.general.play_token = v.trim().to_owned();
|
||||
}
|
||||
if let Some(v) = body.directory_share {
|
||||
cfg.general.directory_share = v;
|
||||
}
|
||||
@@ -2754,15 +3273,31 @@ struct ClientEvent {
|
||||
ms: Option<u64>,
|
||||
/// The list's filter, for `list_shown`: all, unread, flagged.
|
||||
filter: Option<String>,
|
||||
/// For `tap`: the trace the page began and its root span, hex, and when it began, Unix ms.
|
||||
trace: Option<String>,
|
||||
span: Option<String>,
|
||||
start: Option<u64>,
|
||||
/// For `swipe` and `scroll_stuck` (#384): where the reader stood, and what could hold it.
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode {
|
||||
// From the browser: only the kinds the page sends, and nothing long enough to fill the log.
|
||||
let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>());
|
||||
let (msg, src, feed, filter) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter));
|
||||
let (msg, src, feed, filter, state) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter), cut(&e.state));
|
||||
let (ev, line, enclosure, deadair, ms, user) =
|
||||
(e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
|
||||
match ev {
|
||||
// Sent to Tempo, not the log: a line a tap would be most of it.
|
||||
"tap" => {
|
||||
let ok = match (&e.trace, &e.span, e.start, ms) {
|
||||
(Some(t), Some(s), Some(start), Some(ms)) => {
|
||||
page_span(t, s, start, ms, msg.as_deref().unwrap_or(""), user)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
return if ok { StatusCode::NO_CONTENT } else { StatusCode::BAD_REQUEST };
|
||||
}
|
||||
"page_error" | "rejection" | "player_error" => tracing::warn!(
|
||||
target: "ipx::client", ev, msg, src, line, feed, enclosure, deadair, user,
|
||||
"{user}'s page: {ev}: {}", msg.as_deref().unwrap_or("")
|
||||
@@ -2776,11 +3311,54 @@ async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> Stat
|
||||
target: "ipx::client", ev, ms, feed, filter, user,
|
||||
"{user}'s page: list_shown {} ms", ms.unwrap_or(0)
|
||||
),
|
||||
// A swipe in the reader and what came of it, so one that did nothing is seen (#384): a
|
||||
// swipe sends no request when it only bounces, and #383's stopped at the 50th unseen.
|
||||
"swipe" => tracing::info!(
|
||||
target: "ipx::client", ev, msg, ms, feed, filter, state, user,
|
||||
"{user}'s page: swipe {} at {}", msg.as_deref().unwrap_or(""), state.as_deref().unwrap_or("")
|
||||
),
|
||||
// A drag up or down on an item with room to scroll that did not move it (#384).
|
||||
"scroll_stuck" => tracing::warn!(
|
||||
target: "ipx::client", ev, feed, state, user,
|
||||
"{user}'s page: scroll_stuck: {}", state.as_deref().unwrap_or("")
|
||||
),
|
||||
_ => return StatusCode::BAD_REQUEST,
|
||||
}
|
||||
// The timings in /metrics as well, so weeks of them are percentiles without reading each line
|
||||
// (#369). Capped at a minute: the clock is the browser's.
|
||||
if let (Some(ms), "play_start" | "stall" | "list_shown") = (ms, ev) {
|
||||
crate::metrics::PAGE.observe(&format!("ev=\"{ev}\""), ms.min(60_000) as f64 / 1000.0);
|
||||
}
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
/// The root span of a trace the page began with a tap or a key (#376), with the page's own ids, so
|
||||
/// the requests it sent, which name it as their parent, hang from it in Tempo: how long from the
|
||||
/// tap to its last answer. The browser's clock, so only a span begun in the last hour and at most
|
||||
/// a minute long. False for ids that are not a trace's.
|
||||
fn page_span(trace: &str, span: &str, start_ms: u64, ms: u64, what: &str, user: &str) -> bool {
|
||||
use opentelemetry::trace::{Span as _, SpanId, SpanKind, TraceId, Tracer as _};
|
||||
let (Ok(t), Ok(s)) = (TraceId::from_hex(trace), SpanId::from_hex(span)) else { return false };
|
||||
if trace.len() != 32 || span.len() != 16 || t == TraceId::INVALID || s == SpanId::INVALID || ms > 60_000 {
|
||||
return false;
|
||||
}
|
||||
let start = std::time::UNIX_EPOCH + std::time::Duration::from_millis(start_ms);
|
||||
let now = std::time::SystemTime::now();
|
||||
if now.duration_since(start).map_or(true, |ago| ago > std::time::Duration::from_secs(3600)) {
|
||||
return false;
|
||||
}
|
||||
let tracer = opentelemetry::global::tracer("ipx");
|
||||
let b = tracer
|
||||
.span_builder(if what == "keydown" { "key" } else { "tap" })
|
||||
.with_kind(SpanKind::Client)
|
||||
.with_start_time(start)
|
||||
.with_attributes([opentelemetry::KeyValue::new("user", user.to_owned())]);
|
||||
// Built with the page's ids, which its requests already name as their parent.
|
||||
let mut span = ipx_directory::otel::with_ids(t, s, || tracer.build_with_context(b, &opentelemetry::Context::new()));
|
||||
span.end_with_timestamp(start + std::time::Duration::from_millis(ms));
|
||||
true
|
||||
}
|
||||
|
||||
/// Names a request's trace by its route, `POST /api/entries/{feed_id}/{guid}/flags`, once routing
|
||||
/// has found it. Named by the path `access_log` sees, every item's GUID was a trace name of its
|
||||
/// own, and nothing grouped. A route layer, because only one runs after routing. Renamed on the
|
||||
@@ -2859,10 +3437,17 @@ async fn access_log(req: Request, next: Next) -> Response {
|
||||
// The same as fields, for the JSON log (#91). Unrouted, a request has no route.
|
||||
let route = resp.extensions().get::<MatchedPath>().map(|r| r.as_str().to_owned());
|
||||
let method = method.as_str();
|
||||
// The event stream's time is its first byte, not the minutes it stays open.
|
||||
if path != "/api/events" {
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
crate::metrics::STATS.request(method, route.as_deref().unwrap_or(""), status, secs);
|
||||
}
|
||||
// Inside the request's span, so the line carries its trace id and leads to its trace.
|
||||
let _in = span.enter();
|
||||
if resp.status().is_success() || resp.status().is_redirection() {
|
||||
tracing::info!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
|
||||
// Debug: the metric above counts it, Caddy's access log and the span have it too,
|
||||
// and it was over half the log (#364). The page's log view keeps debug.
|
||||
tracing::debug!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
|
||||
} else {
|
||||
tracing::warn!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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,7 +27,8 @@ 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` | The module list and `main`: reads the command line and hands it on | `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` |
|
||||
@@ -68,12 +69,13 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
|
||||
| `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
|
||||
@@ -156,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`,
|
||||
@@ -213,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
|
||||
|
||||
42
docs/cli.md
42
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,11 +29,11 @@ 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; signs the account out everywhere
|
||||
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
|
||||
@@ -43,14 +43,14 @@ 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
|
||||
@@ -60,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`
|
||||
@@ -72,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
|
||||
@@ -137,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]
|
||||
@@ -163,12 +187,17 @@ 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
|
||||
|
||||
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.
|
||||
@@ -364,7 +364,7 @@ erDiagram
|
||||
- `signals` and `signal_verdicts` are created from their entities like every table
|
||||
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
||||
were added to existing tables, so `create_missing` adds each column when it is missing.
|
||||
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
|
||||
- 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.
|
||||
@@ -406,7 +406,7 @@ erDiagram
|
||||
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
|
||||
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
|
||||
on SQLite and Postgres both.
|
||||
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
|
||||
- `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
|
||||
|
||||
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.
|
||||
|
||||
@@ -56,13 +56,13 @@ A database with no accounts creates **admin** on the next daemon start, with the
|
||||
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
|
||||
@@ -107,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}]
|
||||
@@ -194,19 +198,27 @@ ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap
|
||||
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"),
|
||||
@@ -214,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")
|
||||
@@ -230,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"),
|
||||
@@ -398,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).
|
||||
155
ios/README.md
Normal file
155
ios/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# iPX for iOS
|
||||
|
||||
ipx's page in an app, with the audio played by the phone rather than the page: it plays on with
|
||||
the screen locked, and the lock screen, Control Center, headphones and a car over Bluetooth or USB
|
||||
drive it. Everything you see is the page, as on the home screen; the app adds the player behind it.
|
||||
|
||||
## Build it
|
||||
|
||||
On a Mac with Xcode:
|
||||
|
||||
```sh
|
||||
brew install xcodegen
|
||||
cd ios && xcodegen generate && open iPX.xcodeproj
|
||||
```
|
||||
|
||||
Set your team under Signing & Capabilities, pick your phone, and run. The project is generated
|
||||
from `project.yml`, so `.xcodeproj` is not in git: edit the yml. Nothing on Tower can build it.
|
||||
|
||||
A free personal team works, but a build on it expires after seven days and is reinstalled from
|
||||
Xcode. A paid account ($99 a year) makes that a year and adds TestFlight. Playing with the screen
|
||||
locked is a plist key (`UIBackgroundModes: audio`), not an entitlement, so it works on either.
|
||||
CarPlay does not: Apple grants `com.apple.developer.carplay-audio` on request (granted for team
|
||||
8743L8D729 on 2026-11-09), and the App ID needs it ticked under Additional Capabilities.
|
||||
|
||||
## Which server
|
||||
|
||||
The first launch asks, with `https://ipodderx.sdf1.net` filled in; a bare host gets `https://`.
|
||||
Change it in the page's Settings, under This device, or from the alert that comes up when the
|
||||
server cannot be reached. Plain `http://` only on your own network
|
||||
(`http://192.168.1.130:8099`), which is all `NSAllowsLocalNetworking` allows. Sign in on the page
|
||||
that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the
|
||||
player uses (`CookieBridge`). `-ipx.server <url>` as a launch argument overrides it for a run. `-ipx.noBadgePrompt YES` skips the badge permission dialog, which covers the page in a simulator run nobody is there to tap.
|
||||
|
||||
### Opening with no connection
|
||||
|
||||
The page's service worker, which keeps iPX readable offline in a browser (#389), runs in the app
|
||||
only for a server in `WKAppBoundDomains` in `project.yml` (#435): `ipodderx.sdf1.net`, with
|
||||
Cloudflare Access's and Authentik's hosts for signing in. For those the web view is app-bound, and
|
||||
iPX opens without a connection from the second launch on (the first installs the worker). What
|
||||
that costs:
|
||||
|
||||
* The web view goes nowhere outside the list. A link to anywhere else opens in Safari; an
|
||||
embedded player from another site in an item's text may not load.
|
||||
* A server not in the list still opens, but WebKit then cuts the bridge, so the page plays its own
|
||||
audio: no lock screen, no CarPlay, nothing kept on the phone. That includes the LAN address.
|
||||
Another server means adding it to the list (ten at most) and building again.
|
||||
* Signing in that leaves those hosts, through another identity provider, opens in Safari and does
|
||||
not come back. Add its host too.
|
||||
|
||||
A debug build adds `localhost`, so a simulator can test the worker against a page served on the Mac.
|
||||
|
||||
## Player token
|
||||
|
||||
The player normally rides on the web view's cookie, which ends with Cloudflare Access's session.
|
||||
To make it outlast that, open Settings in the app and, under This device, press Make, or paste an
|
||||
ipx API token (Settings, API tokens), with the address to send it to, `https://ipxapi.sdf1.net`. The token is
|
||||
kept in the keychain (`Credentials.swift`). The player's audio, positions, read flags, artwork and
|
||||
the unread badge go to that address with `Authorization: Bearer`; the page still signs in as it
|
||||
did, at the server address. Without a token the player sends only the cookie, as before.
|
||||
|
||||
`ipxapi.sdf1.net` is the tunnel's route to the same ipx-web as `ipodderx.sdf1.net`, with no Access
|
||||
application in front, which would turn a bearer header away. Cloudflare's firewall (a custom rule
|
||||
on the `sdf1.net` zone) blocks every request to it except a bearer-token one for `/api/feeds`,
|
||||
`/api/me`, `/api/art`, `/api/entries/*` and `/media/*`; ipx still checks the token.
|
||||
|
||||
## How it hangs together
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `Sources/App.swift` | the app and its one scene |
|
||||
| `Sources/WebViewController.swift` | the `WKWebView`, the server question, a page that will not load |
|
||||
| `Sources/Bridge.swift` | the messages to and from `web/src/native.ts` |
|
||||
| `Sources/Playback.swift` | `AVPlayer`, the audio session, now playing, the remote commands, saving positions |
|
||||
| `Sources/CookieBridge.swift` | the web view's cookies into `HTTPCookieStorage.shared`, for the player |
|
||||
| `Sources/Server.swift` | which server |
|
||||
| `Sources/Intents.swift`, `Shared/PlayPauseIntent.swift` | Siri and Shortcuts (#427) |
|
||||
| `Sources/LastPlayed.swift` | the last episode and its place, so Resume works with the app closed |
|
||||
| `Widgets/`, `Shared/Snapshot.swift`, `Sources/WidgetFeed.swift` | the widget (#428), and what the app writes for it |
|
||||
|
||||
### Siri and Shortcuts
|
||||
|
||||
With nothing to set up, Siri answers "Resume iPX" (or "Play iPX"), "Pause iPX", "Skip forward
|
||||
in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX"; each is also
|
||||
an action in the Shortcuts app. The playback ones are `AudioPlaybackIntent`s, which iOS runs in the
|
||||
app and lets start audio with the phone locked. Resume carries on with what is loaded, or else the
|
||||
last episode from where it got to (`LastPlayed`), which plays without the page: opening the app
|
||||
afterwards shows the page with nothing in its player, while the lock screen has it.
|
||||
|
||||
### The widget
|
||||
|
||||
Small and medium on the home screen, circular and rectangular on the lock screen: All
|
||||
Subscriptions' unread count and the last episode, with a Resume or Pause button (the same
|
||||
`PlayPauseIntent`). The widget has no sign-in and no player, so it draws what the app last wrote
|
||||
to the App Group `group.net.sdf1.ipx` (`Snapshot`), and the app asks WidgetKit to redraw on a play,
|
||||
a pause, a new episode or a new count, not on every position saved, since iOS rations redraws.
|
||||
With the app closed, ipx's push (#433) carries `content-available`, which wakes the app for a
|
||||
moment to give the widget the new count; iOS rations those wakes too, so the widget can trail the
|
||||
icon. `Shared/` is built into both targets, with `WIDGET` set in the widget's, where the player
|
||||
does not exist.
|
||||
|
||||
## The messages
|
||||
|
||||
The page's half is `web/src/native.ts`, and `node tests/native-bridge.ts` holds it to these. A
|
||||
change to them is a change to both halves in one commit, and the page has to be deployed before
|
||||
an app that needs it.
|
||||
|
||||
Page to app, on `webkit.messageHandlers.ipx`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `{t:"ready", version, rate, volume}` | the bridge is in |
|
||||
| `{t:"load", url, enc, feedId, guid, title, feedTitle, artwork, position, duration, rate, volume}` | the element's `src` was set to an audio file |
|
||||
| `{t:"play"}` `{t:"pause"}` `{t:"stop"}` | |
|
||||
| `{t:"seek", to}` `{t:"rate", v}` `{t:"volume", v}` | |
|
||||
| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one |
|
||||
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
|
||||
| `{t:"clearKept"}` | signing out: everything kept goes |
|
||||
| `{t:"sleep", secs}` | pause after `secs`, fading out over the last ten; 0 turns the timer off (#429) |
|
||||
| `{t:"unread", n}` | All Subscriptions' unread count, whenever the page's list is drawn with a new one: the icon's badge (#421) |
|
||||
| `{t:"haptic", kind}` | a press felt: `tap`, `done` (marked read) or `warn` (an error) (#430) |
|
||||
| `{t:"setServer", url}` | Settings' This device: open another server, which replaces the page (#445) |
|
||||
| `{t:"setToken", api, token?}` `{t:"clearToken"}` | Settings' This device: the player address, and a token to keep in the keychain or none (#445) |
|
||||
| `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) |
|
||||
|
||||
App to page, as `window.ipxNative.on(...)`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `{t:"time", cur, dur}` | `timeupdate` |
|
||||
| `{t:"meta", dur}` | `loadedmetadata`: the page seeks to where you left off |
|
||||
| `{t:"state", playing}` | `play` or `pause` |
|
||||
| `{t:"ended"}` | |
|
||||
| `{t:"error", message}` | the page says so |
|
||||
| `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays |
|
||||
| `{t:"keepFailed", url, message}` | one that could not be kept, and why |
|
||||
| `{t:"host", can}` | at `ready`: what this build does itself (`sleep`, `route`, `settings`), so a page newer than the app offers only those |
|
||||
| `{t:"appSettings", server, api, tokenSet, saved}` | at `ready` and after a change: the app's server, player address and whether a player token is set, for Settings' This device (#445) |
|
||||
| `{t:"appSettingsError", message}` | a server or player address the app would not take |
|
||||
| `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) |
|
||||
| `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
|
||||
| `{t:"slept"}` | the timer ran out and playback paused |
|
||||
| `{t:"nowPlaying", feedId, guid, enc, title, feedTitle, artwork, playing, cur, dur}` | what the phone plays that the page did not choose: the car's pick, and at `ready` and on coming forward whatever is loaded; the page's player bar takes it up (#442) |
|
||||
| `{t:"replaced"}` | builds before `nowPlaying`: the car chose another episode, and the page's player bar closes without saving |
|
||||
|
||||
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
|
||||
its own clock, so both happen while the page is frozen. Video stays in the page.
|
||||
|
||||
## Not yet
|
||||
|
||||
* Without the player token the cookie is the only credential: when Cloudflare Access's expires
|
||||
mid-drive, playback stops with "Sign in again", and opening the app signs in.
|
||||
* CarPlay (`CarScreen.swift`) reads ipx with the player's credentials and nothing else: launched
|
||||
by the car with the phone's app never opened since a restart, it has only cookies saved to disk,
|
||||
and without the player token a lapsed Access cookie shows "iPX can't reach your server". Its
|
||||
lists need a connection; a kept episode plays without one once it is picked.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
ios/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
Normal file
BIN
ios/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
1
ios/Resources/Assets.xcassets/Contents.json
Normal file
1
ios/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "info": { "author": "xcode", "version": 1 } }
|
||||
18
ios/Shared/PlayPauseIntent.swift
Normal file
18
ios/Shared/PlayPauseIntent.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import AppIntents
|
||||
|
||||
/// Play or pause (#427, #428): Siri, Shortcuts and the widget's button. An `AudioPlaybackIntent`,
|
||||
/// so iOS runs it in the app, which may start audio from the background, wherever it is asked
|
||||
/// from. Compiled into the widget too, which needs the type for its button but never runs it.
|
||||
struct PlayPauseIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Play or Pause"
|
||||
static let description = IntentDescription(
|
||||
"Pauses what iPX is playing, or carries on with the last episode where you left off.")
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
#if !WIDGET
|
||||
if Playback.shared.isPlaying { Playback.shared.pause() } else { Playback.shared.resumeLast() }
|
||||
#endif
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
30
ios/Shared/Snapshot.swift
Normal file
30
ios/Shared/Snapshot.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// What the widget shows (#428): the unread count and the episode you were last on. Written by
|
||||
/// the app into the App Group the two share, since the widget has no sign-in and no player of its
|
||||
/// own; it shows what the app last knew.
|
||||
struct Snapshot: Codable, Equatable {
|
||||
var unread: Int?
|
||||
var title: String?
|
||||
var feedTitle: String?
|
||||
var position: Double?
|
||||
var duration: Double?
|
||||
var playing = false
|
||||
|
||||
static let group = "group.net.sdf1.ipx"
|
||||
private static let key = "ipx.snapshot"
|
||||
|
||||
static func load(from defaults: UserDefaults? = UserDefaults(suiteName: group)) -> Snapshot {
|
||||
defaults?.data(forKey: key).flatMap { try? JSONDecoder().decode(Snapshot.self, from: $0) } ?? Snapshot()
|
||||
}
|
||||
|
||||
func save(to defaults: UserDefaults? = UserDefaults(suiteName: group)) {
|
||||
if let data = try? JSONEncoder().encode(self) { defaults?.set(data, forKey: Self.key) }
|
||||
}
|
||||
|
||||
/// How far through, 0 to 1, when both are known.
|
||||
var progress: Double? {
|
||||
guard let position, let duration, duration > 0 else { return nil }
|
||||
return min(1, max(0, position / duration))
|
||||
}
|
||||
}
|
||||
23
ios/Sources/API.swift
Normal file
23
ios/Sources/API.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// Reading ipx without the page: the badge and CarPlay (#420), which has no web view. Signed in
|
||||
/// as the player is, by the credentials or else the cookies the web view left (CookieBridge).
|
||||
enum API {
|
||||
private static let session: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = .shared
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
/// The body of a 200, or nil: not signed in, no server, no connection.
|
||||
static func get(_ path: String) async -> Data? {
|
||||
guard let url = Server.resolveForPlayer(path) else { return nil }
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
guard let (data, resp) = try? await session.data(for: req),
|
||||
(resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
||||
return data
|
||||
}
|
||||
}
|
||||
73
ios/Sources/App.swift
Normal file
73
ios/Sources/App.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
Badge.registerBackgroundRefresh()
|
||||
Offline.shared.resume()
|
||||
Outbox.watch()
|
||||
return true
|
||||
}
|
||||
|
||||
/// A download kept going while the app was closed has finished (#425): the system launched us
|
||||
/// to hand it over, and waits on this handler.
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void) {
|
||||
Offline.shared.backgroundCompletion = completionHandler
|
||||
Offline.shared.resume()
|
||||
}
|
||||
|
||||
/// Apple's address for this phone's copy of the app (#433), handed to the page to register.
|
||||
func application(_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
Push.token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// ipx's count, arriving with the app closed (#433): `content-available` wakes the app for a
|
||||
/// moment, which the badge alone does not, so the widget's count follows the icon's (#428).
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
guard let n = (userInfo["aps"] as? [String: Any])?["badge"] as? Int else {
|
||||
completionHandler(.noData)
|
||||
return
|
||||
}
|
||||
WidgetFeed.update { $0.unread = n }
|
||||
completionHandler(.newData)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
NSLog("ipx: push registration: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would
|
||||
/// be a second scene beside this one.
|
||||
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) {
|
||||
guard let scene = scene as? UIWindowScene else { return }
|
||||
let window = UIWindow(windowScene: scene)
|
||||
window.rootViewController = WebViewController()
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
}
|
||||
|
||||
/// iOS wants the request made as the app leaves, with the user's last look at the list; the
|
||||
/// one made on coming forward stays for a run that was cut short.
|
||||
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||
Badge.scheduleBackgroundRefresh()
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
Badge.requestPermission()
|
||||
Badge.scheduleBackgroundRefresh()
|
||||
Task {
|
||||
await Outbox.flush()
|
||||
await Badge.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
97
ios/Sources/Badge.swift
Normal file
97
ios/Sources/Badge.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import BackgroundTasks
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
/// The number on the icon: All Subscriptions' unread count, as the page's badge shows it (#421).
|
||||
///
|
||||
/// Set when the app comes forward, after the player marks an item read, and by a background
|
||||
/// refresh while it is closed. iOS decides when that runs (a few times a day for an app that is
|
||||
/// opened often), so the number can be hours behind; push would make it exact and needs a sender
|
||||
/// in ipx.
|
||||
enum Badge {
|
||||
static let taskId = "net.sdf1.ipx.refresh"
|
||||
|
||||
private struct Row: Decodable {
|
||||
let id: String
|
||||
let group: String?
|
||||
let unread: Int?
|
||||
let hide_from_all: Bool?
|
||||
}
|
||||
|
||||
/// web/src/feeds.ts `unreadInAll`: a feed counts unless it, or the OPML it came from, is
|
||||
/// hidden from All Subscriptions. Nil when the body is not a feed list, so an error page
|
||||
/// does not turn the badge to zero.
|
||||
static func unread(inAll body: Data) -> Int? {
|
||||
guard let rows = try? JSONDecoder().decode([Row].self, from: body) else { return nil }
|
||||
let hidden = Set(rows.filter { $0.hide_from_all == true }.map(\.id))
|
||||
return rows.reduce(0) { n, f in
|
||||
if f.hide_from_all == true || f.group.map(hidden.contains) == true { return n }
|
||||
return n + max(0, f.unread ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks once, for the badge alone: no alerts, no sounds.
|
||||
static func requestPermission() {
|
||||
// `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page
|
||||
// has nobody to tap Allow, and the dialog covers it.
|
||||
if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return }
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { granted, _ in
|
||||
// Each launch, not once: Apple can change the token, and says to ask every time.
|
||||
guard granted else { return }
|
||||
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's own count, sent as its list changes (#421).
|
||||
@MainActor static func set(_ n: Int) {
|
||||
lastSet = Date()
|
||||
WidgetFeed.update { $0.unread = max(0, n) }
|
||||
Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) }
|
||||
}
|
||||
|
||||
/// When the icon was last set from a count, by either way.
|
||||
@MainActor private static var lastSet = Date.distantPast
|
||||
|
||||
/// While an episode plays with the screen locked the app is awake, unlike the page: so new
|
||||
/// items reach the icon every quarter of an hour, which background refresh does not promise.
|
||||
@MainActor static func refreshIfStale() {
|
||||
guard Date().timeIntervalSince(lastSet) > 15 * 60 else { return }
|
||||
lastSet = Date()
|
||||
Task { await refresh() }
|
||||
}
|
||||
|
||||
/// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer
|
||||
/// with a feed list: an old number is better than a wrong one.
|
||||
@discardableResult
|
||||
static func refresh() async -> Bool {
|
||||
guard let n = await fetch() else { return false }
|
||||
await MainActor.run { set(n) }
|
||||
return true
|
||||
}
|
||||
|
||||
/// All Subscriptions' unread count from ipx, or nil when it cannot be had.
|
||||
static func fetch() async -> Int? {
|
||||
guard let data = await API.get("/api/feeds") else { return nil }
|
||||
return unread(inAll: data)
|
||||
}
|
||||
|
||||
// MARK: - while the app is closed
|
||||
|
||||
/// Before the app finishes launching, which is when iOS wants the handler registered.
|
||||
static func registerBackgroundRefresh() {
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskId, using: nil) { task in
|
||||
// The next one first, so a run that fails or is cut short does not end the chain.
|
||||
scheduleBackgroundRefresh()
|
||||
let work = Task {
|
||||
task.setTaskCompleted(success: await refresh())
|
||||
}
|
||||
task.expirationHandler = { work.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
static func scheduleBackgroundRefresh() {
|
||||
let req = BGAppRefreshTaskRequest(identifier: taskId)
|
||||
req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
|
||||
try? BGTaskScheduler.shared.submit(req)
|
||||
}
|
||||
}
|
||||
204
ios/Sources/Bridge.swift
Normal file
204
ios/Sources/Bridge.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import AVKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// Between the page and the phone's player.
|
||||
///
|
||||
/// The page's half is web/src/native.ts. It replaces the playback surface of the page's media
|
||||
/// element with one that posts here, so player.ts (the player bar, the row buttons, the keys)
|
||||
/// carries on unchanged while this side plays. tests/native-bridge.ts holds the page's half to
|
||||
/// the messages; ios/README.md lists them.
|
||||
@MainActor
|
||||
final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
static let name = "ipx"
|
||||
|
||||
private weak var webView: WKWebView?
|
||||
private let playback: Playback
|
||||
private let impact = UIImpactFeedbackGenerator(style: .light)
|
||||
private let notice = UINotificationFeedbackGenerator()
|
||||
/// The system's AirPlay picker, which has no API to open it but its own button: kept on the
|
||||
/// web view, invisible, and pressed when the page's button is.
|
||||
private lazy var routePicker: AVRoutePickerView = {
|
||||
let v = AVRoutePickerView()
|
||||
v.alpha = 0
|
||||
v.isUserInteractionEnabled = false
|
||||
v.prioritizesVideoDevices = false
|
||||
webView?.addSubview(v)
|
||||
return v
|
||||
}()
|
||||
|
||||
init(webView: WKWebView, playback: Playback) {
|
||||
self.webView = webView
|
||||
self.playback = playback
|
||||
super.init()
|
||||
playback.onTime = { [weak self] cur, dur in
|
||||
// Built up rather than `dur as Any`: a nil cast to Any is an Any holding nil, which
|
||||
// JSONSerialization refuses, and every tick before the length was known would drop.
|
||||
var m: [String: Any] = ["t": "time", "cur": cur]
|
||||
if let dur { m["dur"] = dur }
|
||||
self?.send(m)
|
||||
}
|
||||
playback.onMeta = { [weak self] dur in self?.send(["t": "meta", "dur": dur]) }
|
||||
playback.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
|
||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
||||
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) }
|
||||
playback.onChosen = { [weak self] in self?.sendNowPlaying() }
|
||||
// Again on coming forward: a page frozen while the car chose may have missed it (#442).
|
||||
NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil,
|
||||
queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.sendNowPlaying() }
|
||||
}
|
||||
Push.onToken = { [weak self] _ in self?.sendPush() }
|
||||
Offline.shared.onChange = { [weak self] in self?.sendKept() }
|
||||
Offline.shared.onFail = { [weak self] path, msg in
|
||||
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
||||
}
|
||||
}
|
||||
|
||||
/// What the phone holds, and is fetching, by the paths the page keeps them under (#425).
|
||||
private func sendKept() {
|
||||
send(["t": "kept", "urls": Offline.shared.store.paths,
|
||||
"keeping": Offline.shared.downloading.sorted()])
|
||||
}
|
||||
|
||||
/// A new server, checked, for the web view to load in place of this page (#445).
|
||||
var onServer: ((URL) -> Void)?
|
||||
|
||||
/// The app's own settings for the page's Settings to show (#445); `saved` says a change took.
|
||||
private func sendAppSettings(saved: Bool = false) {
|
||||
send(["t": "appSettings", "server": Server.url?.absoluteString ?? Server.suggested,
|
||||
"api": Server.apiURL?.absoluteString ?? Server.suggestedAPI,
|
||||
"tokenSet": Credentials.apiToken != nil, "saved": saved])
|
||||
}
|
||||
|
||||
/// The page's Settings, setting what an alert on shaking the phone used to (#445).
|
||||
private func setToken(_ m: [String: Any]) {
|
||||
guard let url = Server.normalise(m["api"] as? String ?? ""), url.scheme == "https" else {
|
||||
send(["t": "appSettingsError",
|
||||
"message": "The player address has to be https, such as \(Server.suggestedAPI)."])
|
||||
return
|
||||
}
|
||||
Server.apiURL = url
|
||||
if let token = m["token"] as? String, !token.isEmpty { Credentials.apiToken = token }
|
||||
sendAppSettings(saved: true)
|
||||
}
|
||||
|
||||
private func setServer(_ text: String) {
|
||||
guard let url = Server.normalise(text) else {
|
||||
send(["t": "appSettingsError", "message": "That is not an address the app can open. Plain http "
|
||||
+ "only works on your own network, such as 192.168.1.130:8099."])
|
||||
return
|
||||
}
|
||||
onServer?(url)
|
||||
}
|
||||
|
||||
/// What is loaded, for the page's player bar to take up (#442): the car's pick, or an episode
|
||||
/// playing when the page loads, as when the app was opened from CarPlay. Without it the sound
|
||||
/// went on with no bar on the phone to pause it.
|
||||
private func sendNowPlaying() {
|
||||
guard let item = playback.current else { return }
|
||||
var m: [String: Any] = ["t": "nowPlaying", "feedId": item.feedId, "guid": item.guid, "enc": item.enc,
|
||||
"title": item.title, "feedTitle": item.feedTitle,
|
||||
"playing": playback.wantsToPlay, "cur": playback.currentTime]
|
||||
if let a = item.artwork { m["artwork"] = a }
|
||||
if let d = playback.duration ?? item.duration.map(Double.init) { m["dur"] = d }
|
||||
send(m)
|
||||
}
|
||||
|
||||
/// For the page to register with ipx (#433), at ready and whenever Apple gives a new one.
|
||||
private func sendPush() {
|
||||
guard let token = Push.token else { return }
|
||||
send(["t": "push", "token": token, "sandbox": Push.sandbox])
|
||||
}
|
||||
|
||||
// MARK: - page to host
|
||||
|
||||
func userContentController(_ controller: WKUserContentController,
|
||||
didReceive message: WKScriptMessage) {
|
||||
guard let m = message.body as? [String: Any], let t = m["t"] as? String else { return }
|
||||
switch t {
|
||||
case "ready":
|
||||
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
||||
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
||||
// What this build can do, so a newer page offers nothing it would ask for in vain.
|
||||
send(["t": "host", "can": ["sleep", "route", "settings"]])
|
||||
sendAppSettings()
|
||||
// A page reloaded while the timer runs is told how long is left.
|
||||
if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
|
||||
sendKept()
|
||||
sendPush()
|
||||
sendNowPlaying()
|
||||
case "load":
|
||||
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
||||
let enc = m["enc"] as? Int,
|
||||
let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return }
|
||||
playback.load(.init(
|
||||
url: url, enc: enc, feedId: feedId, guid: guid,
|
||||
title: m["title"] as? String ?? "",
|
||||
feedTitle: m["feedTitle"] as? String ?? "",
|
||||
artwork: m["artwork"] as? String,
|
||||
duration: m["duration"] as? Int))
|
||||
case "play": playback.play()
|
||||
case "pause": playback.pause()
|
||||
case "stop": playback.stop()
|
||||
case "seek": if let to = m["to"] as? Double { playback.seek(to: to) }
|
||||
case "rate": if let v = m["v"] as? Double { playback.setRate(Float(v)) }
|
||||
case "volume": if let v = m["v"] as? Double { playback.setVolume(Float(v)) }
|
||||
// The page sends the path it would have written to, not a time: ours is the clock that
|
||||
// is not stale.
|
||||
case "position": playback.savePosition()
|
||||
case "keep": if let url = m["url"] as? String { Offline.shared.keep(url) }
|
||||
case "unkeep": if let url = m["url"] as? String { Offline.shared.remove(url) }
|
||||
case "clearKept": Offline.shared.removeAll()
|
||||
case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0)
|
||||
case "route": showRoutes(m)
|
||||
case "haptic": haptic(m["kind"] as? String)
|
||||
case "unread": if let n = m["n"] as? Int { Badge.set(n) }
|
||||
case "setServer": setServer(m["url"] as? String ?? "")
|
||||
case "setToken": setToken(m)
|
||||
case "clearToken":
|
||||
Credentials.apiToken = nil
|
||||
sendAppSettings(saved: true)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the picker over the page's button: on an iPhone a sheet, on an iPad a popover
|
||||
/// pointing at it. The page sends where the button is, in the web view's points.
|
||||
private func showRoutes(_ m: [String: Any]) {
|
||||
let n = { (k: String) in m[k] as? Double ?? 0 }
|
||||
routePicker.frame = CGRect(x: n("x"), y: n("y"), width: max(1, n("w")), height: max(1, n("h")))
|
||||
// Its button is made on layout, and opens the picker on its primary action; a version
|
||||
// with nothing on that, as older ones may have, gets the touch that used to do it.
|
||||
routePicker.layoutIfNeeded()
|
||||
guard let button = Self.controls(in: routePicker).first else { return }
|
||||
let primary = button.allTargets.contains {
|
||||
button.actions(forTarget: $0, forControlEvent: .primaryActionTriggered)?.isEmpty == false
|
||||
}
|
||||
button.sendActions(for: primary ? .primaryActionTriggered : .touchUpInside)
|
||||
}
|
||||
|
||||
/// The page's presses felt (#430): light for a tap, the system's success and warning for
|
||||
/// something done and something gone wrong, as Mail and Messages use them.
|
||||
private func haptic(_ kind: String?) {
|
||||
switch kind {
|
||||
case "done": notice.notificationOccurred(.success)
|
||||
case "warn": notice.notificationOccurred(.warning)
|
||||
default: impact.impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
private static func controls(in v: UIView) -> [UIControl] {
|
||||
v.subviews.flatMap { ($0 as? UIControl).map { [$0] } ?? controls(in: $0) }
|
||||
}
|
||||
|
||||
// MARK: - host to page
|
||||
|
||||
private func send(_ payload: [String: Any]) {
|
||||
guard let webView, let data = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let json = String(data: data, encoding: .utf8) else { return }
|
||||
// A frozen or reloading page misses a tick; the next one puts it right.
|
||||
webView.evaluateJavaScript("window.ipxNative && window.ipxNative.on(\(json))", completionHandler: nil)
|
||||
}
|
||||
}
|
||||
245
ios/Sources/CarScreen.swift
Normal file
245
ios/Sources/CarScreen.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import CarPlay
|
||||
import UIKit
|
||||
|
||||
// CarPlay (#420). A car shows no web view, so this is the app's own: three lists from ipx's API,
|
||||
// In Progress, Unread and Shows, read as the badge reads it (API), and the same Playback the
|
||||
// page drives, so a car's pick plays on with the phone locked and saves its place as the page's
|
||||
// does. Now Playing is the system's, fed by Playback's now-playing info and remote commands.
|
||||
|
||||
/// An item as a car's list needs it: db.rs EntryRow, the fields used.
|
||||
struct CarEpisode: Decodable {
|
||||
struct Enclosure: Decodable {
|
||||
let id: Int
|
||||
let url: String
|
||||
let mime: String?
|
||||
let deadair: Double?
|
||||
/// Set once ipx has the file; `/media/{id}` answers 404 before that.
|
||||
let path: String?
|
||||
}
|
||||
|
||||
let guid: String
|
||||
let feed_id: String
|
||||
let title: String?
|
||||
let image: String?
|
||||
let duration: Double?
|
||||
let position: Double
|
||||
let enclosures: [Enclosure]
|
||||
|
||||
/// The file to play: the first downloaded one that is sound (items.ts isPlayable, less video,
|
||||
/// which a car would play the sound of with nothing to say so). An item with none is not listed
|
||||
/// (#443): a post's picture or a file still to come is nothing a car can play.
|
||||
var audio: Enclosure? {
|
||||
enclosures.first { e in
|
||||
guard e.path != nil else { return false }
|
||||
let mime = (e.mime ?? "").lowercased()
|
||||
if !mime.isEmpty { return mime.hasPrefix("audio/") }
|
||||
let ext = URL(string: e.url)?.pathExtension.lowercased() ?? ""
|
||||
return ["mp3", "m4a", "m4b", "aac", "wav", "flac"].contains(ext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Its title, or the file's name: a car's list cannot show the opening of a post as the
|
||||
/// page's does, and "Untitled" twelve times says nothing.
|
||||
var name: String {
|
||||
if let t = title?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty { return t }
|
||||
let file = audio.flatMap { URL(string: $0.url)?.deletingPathExtension().lastPathComponent }
|
||||
return file.flatMap { $0.isEmpty ? nil : $0.removingPercentEncoding ?? $0 } ?? "Untitled episode"
|
||||
}
|
||||
|
||||
/// player.ts `mediaSrc`: the copy kept on the phone if there is one, else DeadAir's when it
|
||||
/// made one, so the clock the place is saved by is the one the page would use.
|
||||
static func path(of e: Enclosure, kept: Set<String>) -> String {
|
||||
let keepPath = (e.deadair ?? 0) > 0 ? "/media/\(e.id)/deadair" : "/media/\(e.id)"
|
||||
if kept.contains(keepPath) { return keepPath }
|
||||
return e.deadair != nil ? "/media/\(e.id)/deadair.m3u8" : "/media/\(e.id)"
|
||||
}
|
||||
|
||||
/// What is left of it, once started: "12 min left".
|
||||
var left: String? {
|
||||
guard position > 5, let d = duration, d > position else { return nil }
|
||||
return "\(max(1, Int(((d - position) / 60).rounded()))) min left"
|
||||
}
|
||||
|
||||
func item(feedTitle: String, feedImage: String?, kept: Set<String>) -> Playback.Item? {
|
||||
guard let enc = audio, let url = Server.resolveForPlayer(Self.path(of: enc, kept: kept)) else { return nil }
|
||||
return Playback.Item(url: url, enc: enc.id, feedId: feed_id, guid: guid, title: name,
|
||||
feedTitle: feedTitle, artwork: image ?? feedImage,
|
||||
duration: duration.map { Int($0) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A subscription, from `/api/feeds`.
|
||||
struct CarFeed: Decodable {
|
||||
let id: String
|
||||
let title: String?
|
||||
let image: String?
|
||||
let unread: Int?
|
||||
let entries: Int?
|
||||
/// Its files ipx has downloaded.
|
||||
let downloaded: Int?
|
||||
let group: String?
|
||||
|
||||
var name: String { title ?? id }
|
||||
}
|
||||
|
||||
private struct CarPage: Decodable {
|
||||
let entries: [CarEpisode]
|
||||
}
|
||||
|
||||
final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
private var screen: CarScreen?
|
||||
|
||||
func templateApplicationScene(_ scene: CPTemplateApplicationScene,
|
||||
didConnect interfaceController: CPInterfaceController) {
|
||||
screen = CarScreen(interface: interfaceController)
|
||||
}
|
||||
|
||||
func templateApplicationScene(_ scene: CPTemplateApplicationScene,
|
||||
didDisconnectInterfaceController interfaceController: CPInterfaceController) {
|
||||
screen = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CarScreen: NSObject, CPTabBarTemplateDelegate {
|
||||
private let interface: CPInterfaceController
|
||||
private let inProgress = CPListTemplate(title: "In Progress", sections: [])
|
||||
private let unread = CPListTemplate(title: "Unread", sections: [])
|
||||
private let shows = CPListTemplate(title: "Shows", sections: [])
|
||||
private var feeds: [String: CarFeed] = [:]
|
||||
private var art: [String: UIImage] = [:]
|
||||
|
||||
init(interface: CPInterfaceController) {
|
||||
self.interface = interface
|
||||
super.init()
|
||||
inProgress.tabImage = UIImage(systemName: "play.circle")
|
||||
unread.tabImage = UIImage(systemName: "tray.full")
|
||||
shows.tabImage = UIImage(systemName: "square.stack")
|
||||
let tabs = CPTabBarTemplate(templates: [inProgress, unread, shows])
|
||||
tabs.delegate = self
|
||||
interface.setRootTemplate(tabs, animated: false, completion: nil)
|
||||
refresh(inProgress)
|
||||
}
|
||||
|
||||
/// Each list again whenever it is shown: a drive is long, and what was unread at the start
|
||||
/// has been heard by the end.
|
||||
func tabBarTemplate(_ tabBarTemplate: CPTabBarTemplate, didSelect selectedTemplate: CPTemplate) {
|
||||
refresh(selectedTemplate)
|
||||
}
|
||||
|
||||
private func refresh(_ t: CPTemplate) {
|
||||
Task {
|
||||
await loadFeeds()
|
||||
// More than the car shows, since items with no file to play are dropped.
|
||||
switch t {
|
||||
case inProgress: await episodes(in: inProgress, from: "/api/entries?filter=in_progress&limit=100",
|
||||
empty: "Nothing started")
|
||||
case unread: await episodes(in: unread, from: "/api/entries?filter=unread&limit=100",
|
||||
empty: "Nothing unread")
|
||||
case shows: showFeeds()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFeeds() async {
|
||||
guard let data = await API.get("/api/feeds"),
|
||||
let rows = try? JSONDecoder().decode([CarFeed].self, from: data) else { return }
|
||||
feeds = Dictionary(rows.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
}
|
||||
|
||||
private func episodes(in list: CPListTemplate, from path: String, empty: String, oneFeed: Bool = false) async {
|
||||
guard let data = await API.get(path), let page = try? JSONDecoder().decode(CarPage.self, from: data) else {
|
||||
unreachable(list)
|
||||
return
|
||||
}
|
||||
let kept = Set(Offline.shared.store.paths)
|
||||
let playing = Playback.shared.current
|
||||
let rows: [CPListItem] = page.entries.compactMap { e in
|
||||
let feed = feeds[e.feed_id]
|
||||
guard let item = e.item(feedTitle: feed?.name ?? e.feed_id, feedImage: feed?.image, kept: kept) else {
|
||||
return nil
|
||||
}
|
||||
// In a show's own list its name is the title above; across shows it is the detail.
|
||||
let detail = (oneFeed ? [e.left] : [item.feedTitle, e.left]).compactMap { $0 }.joined(separator: " · ")
|
||||
let row = CPListItem(text: item.title, detailText: detail.isEmpty ? nil : detail)
|
||||
row.isPlaying = playing?.guid == e.guid && playing?.feedId == e.feed_id
|
||||
row.handler = { [weak self] _, done in
|
||||
self?.play(item, from: e.position)
|
||||
done()
|
||||
}
|
||||
picture(item.artwork, for: row)
|
||||
return row
|
||||
}
|
||||
list.emptyViewTitleVariants = [empty]
|
||||
list.emptyViewSubtitleVariants = []
|
||||
list.updateSections([CPListSection(items: Array(rows.prefix(CPListTemplate.maximumItemCount)))])
|
||||
}
|
||||
|
||||
/// Alphabetical, which a driver can find a show in without reading the whole list. Only
|
||||
/// shows with files downloaded (#443): a blog has nothing a car can play, and an OPML
|
||||
/// subscription's own row holds no items, its feeds do.
|
||||
private func showFeeds() {
|
||||
guard !feeds.isEmpty else {
|
||||
unreachable(shows)
|
||||
return
|
||||
}
|
||||
let sorted = feeds.values.filter { ($0.downloaded ?? 0) > 0 }
|
||||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
let rows: [CPListItem] = sorted.map { f in
|
||||
let n = f.unread ?? 0
|
||||
let row = CPListItem(text: f.name, detailText: n > 0 ? "\(n) unread" : nil)
|
||||
row.accessoryType = .disclosureIndicator
|
||||
row.handler = { [weak self] _, done in
|
||||
self?.open(f)
|
||||
done()
|
||||
}
|
||||
picture(f.image, for: row)
|
||||
return row
|
||||
}
|
||||
shows.emptyViewTitleVariants = ["No shows"]
|
||||
shows.emptyViewSubtitleVariants = []
|
||||
shows.updateSections([CPListSection(items: Array(rows.prefix(CPListTemplate.maximumItemCount)))])
|
||||
}
|
||||
|
||||
private func open(_ f: CarFeed) {
|
||||
let list = CPListTemplate(title: f.name, sections: [])
|
||||
interface.pushTemplate(list, animated: true, completion: nil)
|
||||
let id = f.id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? f.id
|
||||
Task { await episodes(in: list, from: "/api/feeds/\(id)/entries?limit=100", empty: "No episodes", oneFeed: true) }
|
||||
}
|
||||
|
||||
/// Signed out, or no connection. Sign-in is the page's, which a car cannot show.
|
||||
private func unreachable(_ list: CPListTemplate) {
|
||||
list.emptyViewTitleVariants = ["iPX can't reach your server"]
|
||||
list.emptyViewSubtitleVariants = ["Open iPX on your phone and sign in."]
|
||||
list.updateSections([])
|
||||
}
|
||||
|
||||
private func play(_ item: Playback.Item, from position: Double) {
|
||||
Playback.shared.playChosen(item, startAt: position > 5 ? position : nil)
|
||||
interface.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
/// A show's or an episode's picture, fetched through ipx's `/api/art` as the page's are, and
|
||||
/// kept for the drive: the lists are made again on every visit.
|
||||
private func picture(_ src: String?, for row: CPListItem) {
|
||||
guard let src else { return }
|
||||
if let image = art[src] {
|
||||
row.setImage(image)
|
||||
return
|
||||
}
|
||||
let path = src.lowercased().hasPrefix("http")
|
||||
? "/api/art?u=" + (src.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? src)
|
||||
: src
|
||||
Task {
|
||||
guard let data = await API.get(path), let image = UIImage(data: data) else { return }
|
||||
let size = CPListItem.maximumImageSize
|
||||
let small = UIGraphicsImageRenderer(size: size).image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
art[src] = small
|
||||
row.setImage(small)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
ios/Sources/CookieBridge.swift
Normal file
31
ios/Sources/CookieBridge.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// The web view signs in; the player rides on what that left behind.
|
||||
///
|
||||
/// ipx knows who is asking by cookie: `ipx_session` from its own form, `CF_Authorization` from
|
||||
/// Cloudflare Access in front of the tunnel. That is why the player needs no API of its own. But
|
||||
/// AVPlayer and URLSession read `HTTPCookieStorage.shared`, and WKWebView keeps a store of its
|
||||
/// own, so this copies one into the other whenever the web view's changes.
|
||||
final class CookieBridge: NSObject, WKHTTPCookieStoreObserver {
|
||||
private let store: WKHTTPCookieStore
|
||||
|
||||
init(store: WKHTTPCookieStore) {
|
||||
self.store = store
|
||||
super.init()
|
||||
store.add(self)
|
||||
sync()
|
||||
}
|
||||
|
||||
func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { sync() }
|
||||
|
||||
/// All of them, replaced: a cookie the web view dropped (signing out) has to go here too, or
|
||||
/// the player would go on playing as someone who signed out.
|
||||
private func sync() {
|
||||
store.getAllCookies { all in
|
||||
let shared = HTTPCookieStorage.shared
|
||||
shared.cookies?.forEach(shared.deleteCookie)
|
||||
all.forEach(shared.setCookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
ios/Sources/Credentials.swift
Normal file
51
ios/Sources/Credentials.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// What the player and CarPlay send instead of the web view's cookie (#419).
|
||||
///
|
||||
/// The cookie Cloudflare Access leaves in the web view lasts as long as its session, and when it
|
||||
/// ends mid-drive the player has nothing. An ipx API token (Settings, API tokens), which ipx reads
|
||||
/// as `Authorization: Bearer`, does not end by itself. Access would turn a request carrying only
|
||||
/// that away, so the player sends it to a second address with no Access in front, which
|
||||
/// Cloudflare's firewall holds to the paths the player uses (`Server.apiURL`).
|
||||
enum Credentials {
|
||||
private static let service = "net.sdf1.ipx"
|
||||
private static let account = "ipx.api-token"
|
||||
|
||||
static var apiToken: String? {
|
||||
get {
|
||||
var query = base
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var out: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
|
||||
let data = out as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
set {
|
||||
SecItemDelete(base as CFDictionary)
|
||||
guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty else { return }
|
||||
var item = base
|
||||
item[kSecValueData as String] = Data(value.utf8)
|
||||
// After first unlock, not when unlocked: CarPlay and a locked phone both play.
|
||||
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
SecItemAdd(item as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static var base: [String: Any] {
|
||||
[kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account]
|
||||
}
|
||||
|
||||
/// Empty unless there is a token, and an address to send it to: sent to the page's own
|
||||
/// address it would only be turned away by Access.
|
||||
static var headers: [String: String] {
|
||||
guard let token = apiToken, Server.apiURL != nil else { return [:] }
|
||||
return ["Authorization": "Bearer \(token)"]
|
||||
}
|
||||
|
||||
static var isSet: Bool { !headers.isEmpty }
|
||||
}
|
||||
89
ios/Sources/EpisodeStore.swift
Normal file
89
ios/Sources/EpisodeStore.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// The episodes kept on this device (#425): which file holds each, by the path the page keeps it
|
||||
/// under, `/media/42`, or `/media/42/deadair` for the copy without its dead air. A plain value over
|
||||
/// a folder, so a test can point it at a temporary one.
|
||||
struct EpisodeStore {
|
||||
struct Entry: Codable, Equatable {
|
||||
var file: String
|
||||
var bytes: Int64
|
||||
}
|
||||
|
||||
let directory: URL
|
||||
private(set) var entries: [String: Entry]
|
||||
|
||||
private var indexURL: URL { directory.appendingPathComponent("index.json") }
|
||||
|
||||
init(directory: URL) {
|
||||
self.directory = directory
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
// The files can be fetched again, so they are not worth the user's iCloud backup.
|
||||
var dir = directory
|
||||
var values = URLResourceValues()
|
||||
values.isExcludedFromBackup = true
|
||||
try? dir.setResourceValues(values)
|
||||
|
||||
let saved = (try? Data(contentsOf: directory.appendingPathComponent("index.json")))
|
||||
.flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:]
|
||||
// A file the system took back is not kept, whatever the index says.
|
||||
entries = saved.filter {
|
||||
FileManager.default.fileExists(atPath: directory.appendingPathComponent($0.value.file).path)
|
||||
}
|
||||
}
|
||||
|
||||
var paths: [String] { entries.keys.sorted() }
|
||||
|
||||
func fileURL(for path: String) -> URL? {
|
||||
entries[path].map { directory.appendingPathComponent($0.file) }
|
||||
}
|
||||
|
||||
/// Only the page's two kinds of file, which also keeps what a path can make of a file name to
|
||||
/// digits and a word: a message from the page is not trusted to name a place on disk.
|
||||
static func isKeepable(_ path: String) -> Bool {
|
||||
path.range(of: #"^/media/[0-9]+(/deadair)?$"#, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func fileName(for path: String, ext: String) -> String {
|
||||
path.dropFirst().replacingOccurrences(of: "/", with: "-") + "." + ext
|
||||
}
|
||||
|
||||
/// What AVFoundation can tell a file's type from, since a download has no name of its own.
|
||||
static func ext(forMime mime: String?) -> String {
|
||||
switch mime?.lowercased().split(separator: ";").first.map(String.init) {
|
||||
case "audio/mp4", "audio/x-m4a", "audio/m4a": return "m4a"
|
||||
case "audio/aac", "audio/aacp": return "aac"
|
||||
case "audio/ogg", "audio/opus": return "ogg"
|
||||
case "audio/wav", "audio/x-wav": return "wav"
|
||||
case "audio/flac", "audio/x-flac": return "flac"
|
||||
default: return "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the file at `temp`, which the system deletes when the delegate returns, as `path`'s.
|
||||
mutating func add(_ path: String, moving temp: URL, ext: String) throws {
|
||||
precondition(Self.isKeepable(path))
|
||||
let name = Self.fileName(for: path, ext: ext)
|
||||
let dest = directory.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: dest)
|
||||
try FileManager.default.moveItem(at: temp, to: dest)
|
||||
let bytes = (try? FileManager.default.attributesOfItem(atPath: dest.path))?[.size] as? Int64 ?? 0
|
||||
entries[path] = Entry(file: name, bytes: bytes)
|
||||
save()
|
||||
}
|
||||
|
||||
mutating func remove(_ path: String) {
|
||||
guard let e = entries.removeValue(forKey: path) else { return }
|
||||
try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file))
|
||||
save()
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
for e in entries.values { try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file)) }
|
||||
entries = [:]
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(entries) { try? data.write(to: indexURL, options: .atomic) }
|
||||
}
|
||||
}
|
||||
109
ios/Sources/Intents.swift
Normal file
109
ios/Sources/Intents.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import AppIntents
|
||||
|
||||
// Siri and Shortcuts (#427). Each is an App Intent, and IPXShortcuts below gives the common ones
|
||||
// phrases that work with nothing set up: "Resume iPX", "Pause iPX", "How many unread in iPX".
|
||||
// The playback ones are AudioPlaybackIntents, which iOS runs in the app and lets start audio
|
||||
// from the background, so they work with the phone locked and the app closed.
|
||||
|
||||
struct ResumeIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Resume Listening"
|
||||
static let description = IntentDescription(
|
||||
"Carries on with what iPX was playing, or the last episode, from where you left off.")
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard Playback.shared.resumeLast() else { throw NothingToResume() }
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// What Siri says when there is no episode to carry on with.
|
||||
struct NothingToResume: Error, CustomLocalizedStringResourceConvertible {
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
"There is nothing to resume. Play an episode in iPX first."
|
||||
}
|
||||
}
|
||||
|
||||
struct PauseIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Pause"
|
||||
static let description = IntentDescription("Pauses what iPX is playing.")
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
Playback.shared.pause()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct SkipForwardIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Skip Forward"
|
||||
static let description = IntentDescription("Skips 30 seconds ahead, as the player's button does.")
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
Playback.shared.seek(to: Playback.shared.currentTime + 30)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct SkipBackIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Skip Back"
|
||||
static let description = IntentDescription("Goes back 15 seconds, as the player's button does.")
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
Playback.shared.seek(to: Playback.shared.currentTime - 15)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct SleepTimerIntent: AudioPlaybackIntent {
|
||||
static let title: LocalizedStringResource = "Set Sleep Timer"
|
||||
static let description = IntentDescription(
|
||||
"Pauses iPX after a number of minutes, fading out over the last ten seconds. Zero turns it off.")
|
||||
|
||||
@Parameter(title: "Minutes", default: 30, inclusiveRange: (0, 600),
|
||||
requestValueDialog: "For how many minutes?")
|
||||
var minutes: Int
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
Playback.shared.sleep(after: Double(minutes) * 60)
|
||||
return .result(dialog: minutes == 0 ? "The sleep timer is off." : "iPX will pause in \(minutes) minutes.")
|
||||
}
|
||||
}
|
||||
|
||||
struct UnreadIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Unread Count"
|
||||
static let description = IntentDescription("How many items are unread in All Subscriptions.")
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<Int> & ProvidesDialog {
|
||||
guard let n = await Badge.fetch() else {
|
||||
return .result(value: 0, dialog: "iPX could not reach your server.")
|
||||
}
|
||||
await MainActor.run { Badge.set(n) }
|
||||
return .result(value: n, dialog: n == 0 ? "Nothing is unread." : "\(n) unread in All Subscriptions.")
|
||||
}
|
||||
}
|
||||
|
||||
struct IPXShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(intent: ResumeIntent(),
|
||||
phrases: ["Resume \(.applicationName)", "Play \(.applicationName)",
|
||||
"Carry on listening in \(.applicationName)"],
|
||||
shortTitle: "Resume", systemImageName: "play.fill")
|
||||
AppShortcut(intent: PauseIntent(), phrases: ["Pause \(.applicationName)"],
|
||||
shortTitle: "Pause", systemImageName: "pause.fill")
|
||||
AppShortcut(intent: SkipForwardIntent(),
|
||||
phrases: ["Skip forward in \(.applicationName)", "Skip ahead in \(.applicationName)"],
|
||||
shortTitle: "Skip Forward", systemImageName: "goforward.30")
|
||||
AppShortcut(intent: SkipBackIntent(), phrases: ["Skip back in \(.applicationName)"],
|
||||
shortTitle: "Skip Back", systemImageName: "gobackward.15")
|
||||
AppShortcut(intent: SleepTimerIntent(),
|
||||
phrases: ["Set a sleep timer in \(.applicationName)", "\(.applicationName) sleep timer"],
|
||||
shortTitle: "Sleep Timer", systemImageName: "moon.zzz")
|
||||
AppShortcut(intent: UnreadIntent(),
|
||||
phrases: ["How many unread in \(.applicationName)", "What's new in \(.applicationName)"],
|
||||
shortTitle: "Unread", systemImageName: "tray.full")
|
||||
}
|
||||
}
|
||||
24
ios/Sources/LastPlayed.swift
Normal file
24
ios/Sources/LastPlayed.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
/// The episode the player last had and where it got to, kept across launches, so "Resume iPX"
|
||||
/// (#427) and the widget's button carry on with it when the app was closed and no page is loaded
|
||||
/// to say what to play.
|
||||
enum LastPlayed {
|
||||
private static let itemKey = "ipx.last.item"
|
||||
private static let positionKey = "ipx.last.position"
|
||||
static var defaults = UserDefaults.standard
|
||||
|
||||
static var item: Playback.Item? {
|
||||
get { defaults.data(forKey: itemKey).flatMap { try? JSONDecoder().decode(Playback.Item.self, from: $0) } }
|
||||
set {
|
||||
// A different episode starts from where the page put it, not the last one's place.
|
||||
if newValue != item { defaults.removeObject(forKey: positionKey) }
|
||||
defaults.set(newValue.flatMap { try? JSONEncoder().encode($0) }, forKey: itemKey)
|
||||
}
|
||||
}
|
||||
|
||||
static var position: Double? {
|
||||
get { defaults.object(forKey: positionKey) as? Double }
|
||||
set { defaults.set(newValue, forKey: positionKey) }
|
||||
}
|
||||
}
|
||||
124
ios/Sources/Offline.swift
Normal file
124
ios/Sources/Offline.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
|
||||
/// Episodes kept on the device to play with no connection (#425). The page's Keep button asks
|
||||
/// (through the bridge); a background `URLSession` fetches, so a download goes on with the app
|
||||
/// closed and the phone locked; `Playback` plays from the file when there is one.
|
||||
///
|
||||
/// A browser keeps them in a cache its service worker answers from, which a `WKWebView` does not
|
||||
/// run, so the app keeps its own and the page is told which it holds.
|
||||
@MainActor
|
||||
final class Offline: NSObject, URLSessionDownloadDelegate {
|
||||
static let shared = Offline()
|
||||
|
||||
/// What is kept, or being fetched, changed.
|
||||
var onChange: (() -> Void)?
|
||||
/// A path that could not be kept, and why in words for the person.
|
||||
var onFail: ((String, String) -> Void)?
|
||||
/// The system's handler for a launch it made to deliver a finished download: told when done.
|
||||
var backgroundCompletion: (() -> Void)?
|
||||
|
||||
private(set) var store: EpisodeStore
|
||||
private(set) var downloading: Set<String> = []
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let cfg = URLSessionConfiguration.background(withIdentifier: "net.sdf1.ipx.offline")
|
||||
cfg.sessionSendsLaunchEvents = true
|
||||
cfg.httpCookieStorage = .shared
|
||||
// The delegate's queue is main, which is what lets this class be the main actor's.
|
||||
return URLSession(configuration: cfg, delegate: self, delegateQueue: .main)
|
||||
}()
|
||||
|
||||
private override init() {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
store = EpisodeStore(directory: base.appendingPathComponent("episodes", isDirectory: true))
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// At launch: reconnects to downloads begun before the app was last closed, so the page can be
|
||||
/// told they are still going.
|
||||
func resume() {
|
||||
session.getAllTasks { [weak self] tasks in
|
||||
let paths = tasks.compactMap { $0.taskDescription }
|
||||
Task { @MainActor in
|
||||
self?.downloading.formUnion(paths)
|
||||
if !paths.isEmpty { self?.onChange?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keep(_ path: String) {
|
||||
guard EpisodeStore.isKeepable(path) else { return }
|
||||
guard store.fileURL(for: path) == nil, !downloading.contains(path) else { onChange?(); return }
|
||||
guard let url = Server.resolveForPlayer(path) else {
|
||||
onFail?(path, "Set the server first.")
|
||||
return
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
let task = session.downloadTask(with: req)
|
||||
task.taskDescription = path
|
||||
downloading.insert(path)
|
||||
task.resume()
|
||||
onChange?()
|
||||
}
|
||||
|
||||
func remove(_ path: String) {
|
||||
session.getAllTasks { tasks in tasks.filter { $0.taskDescription == path }.forEach { $0.cancel() } }
|
||||
downloading.remove(path)
|
||||
store.remove(path)
|
||||
onChange?()
|
||||
}
|
||||
|
||||
/// Signing out: the next person on this phone is not to hear what was kept for the last.
|
||||
func removeAll() {
|
||||
session.getAllTasks { tasks in tasks.forEach { $0.cancel() } }
|
||||
downloading = []
|
||||
store.removeAll()
|
||||
onChange?()
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDownloadDelegate
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL) {
|
||||
// On the main queue (see `session`), and the file is the system's until this returns, so
|
||||
// it is moved here and not later.
|
||||
MainActor.assumeIsolated {
|
||||
guard let path = downloadTask.taskDescription else { return }
|
||||
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard status == 200 else {
|
||||
downloading.remove(path)
|
||||
onFail?(path, status == 401 || status == 403
|
||||
? "Check the player token in Settings." : "The server said \(status).")
|
||||
onChange?()
|
||||
return
|
||||
}
|
||||
let ext = EpisodeStore.ext(forMime: downloadTask.response?.mimeType)
|
||||
do {
|
||||
try store.add(path, moving: location, ext: ext)
|
||||
} catch {
|
||||
onFail?(path, "There is not enough room on this phone to keep it.")
|
||||
}
|
||||
downloading.remove(path)
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let error, (error as NSError).code != NSURLErrorCancelled else { return }
|
||||
MainActor.assumeIsolated {
|
||||
guard let path = task.taskDescription else { return }
|
||||
downloading.remove(path)
|
||||
onFail?(path, (error as NSError).code == NSURLErrorNotConnectedToInternet
|
||||
? "You are offline. Keep it once you are back." : "Unable to keep it on this device. Try again.")
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
MainActor.assumeIsolated {
|
||||
backgroundCompletion?()
|
||||
backgroundCompletion = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
66
ios/Sources/Outbox.swift
Normal file
66
ios/Sources/Outbox.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Writes the player made with no connection, sent when there is one (#425): where you got to in
|
||||
/// an episode, and that you finished it. In a browser the page's service worker holds these; the
|
||||
/// app's player is the one writing them, so it holds its own.
|
||||
///
|
||||
/// A newer write to the same address replaces an older one, since each says where you are now, and
|
||||
/// the queue then stays as long as the episodes played offline, not as long as the time spent.
|
||||
enum Outbox {
|
||||
struct Write: Codable, Equatable {
|
||||
var path: String
|
||||
var json: Data
|
||||
}
|
||||
|
||||
private static let key = "ipx.outbox"
|
||||
private static var flushing = false
|
||||
private static let monitor = NWPathMonitor()
|
||||
|
||||
static var pending: [Write] {
|
||||
get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode([Write].self, from: $0) } ?? [] }
|
||||
set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
|
||||
}
|
||||
|
||||
/// A write that never arrived. Only the player's own two kinds are kept: anything else, such
|
||||
/// as adding a feed, fails offline as it always has.
|
||||
static func add(path: String, json: [String: Any]) {
|
||||
guard path.hasSuffix("/position") || path.hasSuffix("/flags"),
|
||||
let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) else { return }
|
||||
var all = pending.filter { $0.path != path }
|
||||
all.append(Write(path: path, json: data))
|
||||
pending = all
|
||||
}
|
||||
|
||||
/// In the order they were made. The first that does not reach the server stops it for the next
|
||||
/// try; one the server answered at all is done, whatever it said, as the worker treats it.
|
||||
static func flush() async {
|
||||
guard !flushing, !pending.isEmpty else { return }
|
||||
flushing = true
|
||||
defer { flushing = false }
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = .shared
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
let session = URLSession(configuration: cfg)
|
||||
for w in pending {
|
||||
guard let url = Server.resolveForPlayer(w.path) else { return }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
req.httpBody = w.json
|
||||
guard (try? await session.data(for: req)) != nil else { return }
|
||||
// Removed by what it is, not where it was: a newer write may have replaced it while
|
||||
// this one was on its way.
|
||||
pending.removeAll { $0 == w }
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends what waited whenever the phone gets a connection back.
|
||||
static func watch() {
|
||||
monitor.pathUpdateHandler = { path in
|
||||
if path.status == .satisfied { Task { await flush() } }
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
421
ios/Sources/Playback.swift
Normal file
421
ios/Sources/Playback.swift
Normal file
@@ -0,0 +1,421 @@
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
|
||||
/// The phone's player: the one that plays on with the screen locked, and the one the lock screen
|
||||
/// and a car's buttons reach. Knows nothing of the web view; the bridge drives it.
|
||||
@MainActor
|
||||
final class Playback: NSObject {
|
||||
/// One for the app: the page, Siri, Shortcuts and the widget's button all drive it (#427), and
|
||||
/// the last three with no page loaded at all.
|
||||
static let shared = Playback()
|
||||
|
||||
/// What is loaded: enough for the lock screen, and to save a position without asking the page.
|
||||
struct Item: Codable, Equatable {
|
||||
var url: URL
|
||||
var enc: Int
|
||||
var feedId: String
|
||||
var guid: String
|
||||
var title: String
|
||||
var feedTitle: String
|
||||
var artwork: String?
|
||||
var duration: Int?
|
||||
}
|
||||
|
||||
var onTime: ((Double, Double?) -> Void)?
|
||||
var onMeta: ((Double) -> Void)?
|
||||
var onState: ((Bool) -> Void)?
|
||||
var onEnded: (() -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
/// The sleep timer ran out and playback paused.
|
||||
var onSlept: (() -> Void)?
|
||||
/// Something the page did not choose is loaded now: the car's pick (#420).
|
||||
var onChosen: (() -> Void)?
|
||||
|
||||
private let session: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = .shared
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
private var player: AVPlayer?
|
||||
private var item: Item?
|
||||
private var timeObserver: Any?
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
private var endObserver: NSObjectProtocol?
|
||||
private var lastSaved: Double = 0
|
||||
/// Where to start once the file is ready, for an episode resumed without the page (#427),
|
||||
/// whose own seek on loadedmetadata is what places it otherwise.
|
||||
private var startAt: Double?
|
||||
private var markedRead = false
|
||||
private var rate: Float = 1
|
||||
private var volume: Float = 1
|
||||
private var sleepTimer: Timer?
|
||||
/// When the sleep timer pauses playback, if it is set.
|
||||
private(set) var sleepAt: Date?
|
||||
|
||||
var isPlaying: Bool { player?.timeControlStatus == .playing }
|
||||
/// Playing, or about to once it has buffered: what a player bar's button should say.
|
||||
var wantsToPlay: Bool { player.map { $0.timeControlStatus != .paused } ?? false }
|
||||
/// What is loaded, for the car's lists to mark.
|
||||
var current: Item? { item }
|
||||
var currentTime: Double { player?.currentTime().seconds ?? 0 }
|
||||
var duration: Double? {
|
||||
guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil }
|
||||
return d
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
// spokenAudio is what tells the system this is speech, so navigation's voice ducks it
|
||||
// properly, and longFormAudio is what routes it as a podcast does.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
|
||||
policy: .longFormAudio)
|
||||
} catch {
|
||||
NSLog("ipx: audio session: %@", error.localizedDescription)
|
||||
}
|
||||
wireRemoteCommands()
|
||||
}
|
||||
|
||||
// MARK: - what the page asks for
|
||||
|
||||
func load(_ item: Item, startAt: Double? = nil) {
|
||||
stop(notify: false)
|
||||
self.item = item
|
||||
self.startAt = startAt
|
||||
markedRead = false
|
||||
lastSaved = 0
|
||||
LastPlayed.item = item
|
||||
WidgetFeed.update { s in
|
||||
s.title = item.title
|
||||
s.feedTitle = item.feedTitle
|
||||
s.duration = item.duration.map(Double.init)
|
||||
s.position = startAt
|
||||
s.playing = false
|
||||
}
|
||||
|
||||
// An AVURLAsset does not read the shared cookie store by itself, and the cookie is how
|
||||
// ipx knows whose file this is, unless the credentials are set, which do not expire.
|
||||
// The header option has no constant in the SDK; AVFoundation reads it by this name.
|
||||
var options: [String: Any] = [
|
||||
AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [],
|
||||
]
|
||||
if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers }
|
||||
// One kept on the phone plays from there, connection or not (#425). The page asks for it
|
||||
// as `/media/42?offline`; the path alone is what it was kept under.
|
||||
let asset = Offline.shared.store.fileURL(for: item.url.path).map { AVURLAsset(url: $0) }
|
||||
?? AVURLAsset(url: item.url, options: options)
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
let player = AVPlayer(playerItem: playerItem)
|
||||
player.volume = volume
|
||||
player.defaultRate = rate
|
||||
self.player = player
|
||||
|
||||
// Where to start is the page's to decide, on loadedmetadata, so one piece of code owns it.
|
||||
// Here only the length is reported.
|
||||
statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch pi.status {
|
||||
case .readyToPlay:
|
||||
if let at = self.startAt {
|
||||
self.startAt = nil
|
||||
self.seek(to: at)
|
||||
}
|
||||
if let d = self.duration { self.onMeta?(d) }
|
||||
case .failed:
|
||||
// A file that will not play, or a session that went while it played: only the
|
||||
// second has something to do about it.
|
||||
let msg = pi.error?.localizedDescription ?? "the file could not be played"
|
||||
let status = await self.request("GET", "/api/me")
|
||||
let expired = Credentials.isSet ? "Check the player token in Settings" : "Sign in again"
|
||||
self.onError?(status == 401 || status == 403 ? expired : msg)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
timeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main
|
||||
) { [weak self] t in
|
||||
MainActor.assumeIsolated { self?.tick(t.seconds) }
|
||||
}
|
||||
endObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.didEnd() }
|
||||
}
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard let player else { return }
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
player.playImmediately(atRate: rate)
|
||||
onState?(true)
|
||||
updateNowPlaying()
|
||||
WidgetFeed.update { $0.playing = true }
|
||||
}
|
||||
|
||||
/// Carries on: what is loaded, or else the last episode, from where it got to (#427). False
|
||||
/// when there is nothing to resume.
|
||||
@discardableResult
|
||||
func resumeLast() -> Bool {
|
||||
if player == nil {
|
||||
guard let last = LastPlayed.item else { return false }
|
||||
load(last, startAt: LastPlayed.position)
|
||||
}
|
||||
play()
|
||||
return true
|
||||
}
|
||||
|
||||
/// An episode chosen away from the page, in the car (#420): played from where it got to, and
|
||||
/// the page told before it plays, so its player bar shows this one, not the old one or none
|
||||
/// (#442), and the state that follows reaches it.
|
||||
func playChosen(_ item: Item, startAt: Double?) {
|
||||
if item == self.item {
|
||||
play()
|
||||
return
|
||||
}
|
||||
load(item, startAt: startAt)
|
||||
onChosen?()
|
||||
play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
onState?(false)
|
||||
savePosition()
|
||||
updateNowPlaying()
|
||||
WidgetFeed.update { $0.playing = false }
|
||||
}
|
||||
|
||||
func seek(to secs: Double) {
|
||||
// Exact, not the default tolerance, which snaps to a keyframe minutes from where the
|
||||
// scrubber was let go on a long episode.
|
||||
player?.seek(to: CMTime(seconds: max(0, secs), preferredTimescale: 600),
|
||||
toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setRate(_ r: Float) {
|
||||
rate = r
|
||||
player?.defaultRate = r
|
||||
// Setting rate on a paused player starts it, which changing the speed should not do.
|
||||
if isPlaying { player?.rate = r }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setVolume(_ v: Float) {
|
||||
volume = v
|
||||
player?.volume = v
|
||||
}
|
||||
|
||||
/// The sleep timer (#429): pauses after `secs`, the last ten fading out; 0 turns it off. Kept
|
||||
/// here, not in the page: with the screen locked the web view is frozen, and a timer of its own
|
||||
/// would not fire until the app was opened again. This one fires because the app is playing.
|
||||
func sleep(after secs: Double) {
|
||||
sleepTimer?.invalidate()
|
||||
sleepTimer = nil
|
||||
sleepAt = nil
|
||||
player?.volume = volume
|
||||
guard secs > 0 else { return }
|
||||
sleepAt = Date(timeIntervalSinceNow: secs)
|
||||
sleepTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.sleepTick() }
|
||||
}
|
||||
}
|
||||
|
||||
private func sleepTick() {
|
||||
guard let sleepAt else { return }
|
||||
let left = sleepAt.timeIntervalSinceNow
|
||||
// A fade, not a cut: a sudden stop wakes the person it was set for.
|
||||
if left > 0 {
|
||||
if left < 10 { player?.volume = volume * Float(left / 10) }
|
||||
return
|
||||
}
|
||||
sleep(after: 0)
|
||||
pause()
|
||||
onSlept?()
|
||||
}
|
||||
|
||||
func stop(notify: Bool = true) {
|
||||
sleep(after: 0)
|
||||
savePosition()
|
||||
if let timeObserver { player?.removeTimeObserver(timeObserver) }
|
||||
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
|
||||
timeObserver = nil
|
||||
endObserver = nil
|
||||
statusObservation = nil
|
||||
player?.pause()
|
||||
player = nil
|
||||
item = nil
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
if notify { onState?(false) }
|
||||
WidgetFeed.update { $0.playing = false }
|
||||
}
|
||||
|
||||
/// Where we are, saved from this clock. The page asks for it too, and sends no time of its
|
||||
/// own: a web view frozen in the background holds a time from minutes ago, and writing that
|
||||
/// over where you got to is the stale write player.ts guards against.
|
||||
func savePosition() {
|
||||
guard let item, let player, player.currentItem?.status == .readyToPlay else { return }
|
||||
let secs = Int(player.currentTime().seconds.rounded())
|
||||
guard secs > 0 else { return }
|
||||
lastSaved = Double(secs)
|
||||
LastPlayed.position = Double(secs)
|
||||
WidgetFeed.update(reload: false) { s in
|
||||
s.position = Double(secs)
|
||||
if let d = duration { s.duration = d }
|
||||
}
|
||||
var body: [String: Any] = ["secs": secs]
|
||||
if let d = duration { body["duration"] = Int(d.rounded()) }
|
||||
// DeadAir's copy runs on a shorter clock; the server has to know which one this is.
|
||||
if item.url.path.contains("/deadair") { body["deadair"] = item.enc }
|
||||
let path = entryPath(item, "position")
|
||||
// Not arrived, kept for when the connection is back: offline, the ten-second retry would
|
||||
// only fail again, and the place reached on the last pause would be lost.
|
||||
Task { if await request("POST", path, body) == nil { Outbox.add(path: path, json: body) } }
|
||||
}
|
||||
|
||||
// MARK: - as it plays
|
||||
|
||||
private func tick(_ secs: Double) {
|
||||
guard secs.isFinite else { return }
|
||||
onTime?(secs, duration)
|
||||
updateElapsed(secs)
|
||||
if abs(secs - lastSaved) > 10 { savePosition() }
|
||||
Badge.refreshIfStale()
|
||||
// Heard, not just started: the same 90% the page uses, so an item does not leave Unread
|
||||
// the moment it is pressed. The page does the same while it runs; this is for when it
|
||||
// does not.
|
||||
if let d = duration, secs / d >= 0.9 { markRead() }
|
||||
}
|
||||
|
||||
private func didEnd() {
|
||||
savePosition()
|
||||
// Finished: nothing to resume, and the widget says so.
|
||||
LastPlayed.item = nil
|
||||
WidgetFeed.update { s in
|
||||
s.title = nil
|
||||
s.feedTitle = nil
|
||||
s.position = nil
|
||||
s.playing = false
|
||||
}
|
||||
markRead()
|
||||
onState?(false)
|
||||
onEnded?()
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func markRead() {
|
||||
guard !markedRead, let item else { return }
|
||||
markedRead = true
|
||||
let path = entryPath(item, "flags")
|
||||
Task {
|
||||
if await request("POST", path, ["read": true]) == nil { Outbox.add(path: path, json: ["read": true]) }
|
||||
await Badge.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - the lock screen and the car
|
||||
|
||||
private func wireRemoteCommands() {
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
c.playCommand.addTarget { [weak self] _ in self?.play(); return .success }
|
||||
c.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
|
||||
c.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
guard let self else { return .commandFailed }
|
||||
self.isPlaying ? self.pause() : self.play()
|
||||
return .success
|
||||
}
|
||||
// The page's 15 back and 30 forward, so a steering wheel and the keyboard agree.
|
||||
c.skipBackwardCommand.preferredIntervals = [15]
|
||||
c.skipForwardCommand.preferredIntervals = [30]
|
||||
c.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
guard let self else { return .commandFailed }
|
||||
self.seek(to: self.currentTime - 15)
|
||||
return .success
|
||||
}
|
||||
c.skipForwardCommand.addTarget { [weak self] _ in
|
||||
guard let self else { return .commandFailed }
|
||||
self.seek(to: self.currentTime + 30)
|
||||
return .success
|
||||
}
|
||||
c.changePlaybackPositionCommand.addTarget { [weak self] ev in
|
||||
guard let self, let e = ev as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .commandFailed
|
||||
}
|
||||
self.seek(to: e.positionTime)
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
guard let item else { return }
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: item.title,
|
||||
MPMediaItemPropertyArtist: item.feedTitle,
|
||||
MPMediaItemPropertyAlbumTitle: item.feedTitle,
|
||||
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? rate : 0,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
|
||||
]
|
||||
if let d = duration ?? item.duration.map(Double.init) {
|
||||
info[MPMediaItemPropertyPlaybackDuration] = d
|
||||
}
|
||||
let centre = MPNowPlayingInfoCenter.default()
|
||||
centre.nowPlayingInfo = info
|
||||
|
||||
guard let art = item.artwork, let url = Server.resolveForPlayer(art) else { return }
|
||||
Task {
|
||||
var artReq = URLRequest(url: url)
|
||||
Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) }
|
||||
guard let (data, _) = try? await session.data(for: artReq),
|
||||
let image = UIImage(data: data), self.item?.guid == item.guid else { return }
|
||||
// Read back, not `info`: by the time the picture arrives that elapsed time is stale,
|
||||
// and writing it would jerk the progress bar back.
|
||||
var latest = centre.nowPlayingInfo ?? [:]
|
||||
latest[MPMediaItemPropertyArtwork] = Self.artwork(image)
|
||||
centre.nowPlayingInfo = latest
|
||||
}
|
||||
}
|
||||
|
||||
/// Made outside the main actor: the system asks for the picture on a thread of its own.
|
||||
nonisolated private static func artwork(_ image: UIImage) -> MPMediaItemArtwork {
|
||||
MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
}
|
||||
|
||||
/// The clock alone, each tick: rewriting the whole dictionary that often makes a car's
|
||||
/// progress bar stutter.
|
||||
private func updateElapsed(_ secs: Double) {
|
||||
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = secs
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? rate : 0
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
|
||||
// MARK: - ipx
|
||||
|
||||
private func entryPath(_ item: Item, _ what: String) -> String {
|
||||
let esc = { (s: String) in s.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? s }
|
||||
return "/api/entries/\(esc(item.feedId))/\(esc(item.guid))/\(what)"
|
||||
}
|
||||
|
||||
/// One call to ipx, signed in by the credentials or else the web view's cookies; its status, or nil if it never
|
||||
/// arrived. A position that does not save is saved again ten seconds on.
|
||||
@discardableResult
|
||||
private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? {
|
||||
guard let url = Server.resolveForPlayer(path) else { return nil }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
if let json {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: json)
|
||||
}
|
||||
guard let (_, resp) = try? await session.data(for: req) else { return nil }
|
||||
return (resp as? HTTPURLResponse)?.statusCode
|
||||
}
|
||||
}
|
||||
23
ios/Sources/Push.swift
Normal file
23
ios/Sources/Push.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// This phone's push token (#433). ipx sends the icon's count to it while the app is closed,
|
||||
/// which background refresh only guessed at. The page registers it with ipx, signed in as the
|
||||
/// page is, since the player's address lets through only the player's own paths.
|
||||
@MainActor
|
||||
enum Push {
|
||||
/// Told when the token arrives or changes, to pass it to the page.
|
||||
static var onToken: ((String) -> Void)?
|
||||
|
||||
static var token: String? {
|
||||
didSet { if let token, token != oldValue { onToken?(token) } }
|
||||
}
|
||||
|
||||
/// A debug build is served by Apple's sandbox, which ipx has to send to instead.
|
||||
static var sandbox: Bool {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
69
ios/Sources/Server.swift
Normal file
69
ios/Sources/Server.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
/// Which ipx-web the app shows. Asked for on first launch; changed in the page's Settings (#445).
|
||||
/// `-ipx.server <url>` on the launch arguments overrides it for that run (UserDefaults' argument
|
||||
/// domain), which is how a test points the app somewhere else.
|
||||
enum Server {
|
||||
private static let key = "ipx.server"
|
||||
static let suggested = "https://ipodderx.sdf1.net"
|
||||
|
||||
static var url: URL? {
|
||||
get { UserDefaults.standard.string(forKey: key).flatMap(normalise) }
|
||||
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: key) }
|
||||
}
|
||||
|
||||
/// What someone typed, as an address: a bare host gets https. Plain http is allowed only on
|
||||
/// your own network, which is all NSAllowsLocalNetworking lets through, so it is refused here
|
||||
/// rather than failing later and looking like the server is down.
|
||||
static func normalise(_ text: String) -> URL? {
|
||||
var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s.isEmpty { return nil }
|
||||
if !s.contains("://") { s = "https://" + s }
|
||||
guard let u = URL(string: s), let host = u.host, !host.isEmpty else { return nil }
|
||||
switch u.scheme {
|
||||
case "https": return u
|
||||
case "http": return isLocal(host) ? u : nil
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func isLocal(_ host: String) -> Bool {
|
||||
if host == "localhost" || host.hasSuffix(".local") { return true }
|
||||
let p = host.split(separator: ".").compactMap { Int($0) }
|
||||
guard p.count == 4 else { return false }
|
||||
return p[0] == 10 || p[0] == 127 || (p[0] == 192 && p[1] == 168)
|
||||
|| (p[0] == 172 && (16...31).contains(p[1]))
|
||||
}
|
||||
|
||||
/// The hosts in the Info.plist's WKAppBoundDomains (#435): the server and where its sign-in
|
||||
/// goes. Fixed when the app is built, as Apple has it; a debug build adds localhost.
|
||||
static let appBoundHosts = Set(Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") as? [String] ?? [])
|
||||
|
||||
static func isAppBound(_ url: URL?) -> Bool {
|
||||
url?.host.map { appBoundHosts.contains($0.lowercased()) } ?? false
|
||||
}
|
||||
|
||||
/// A path the page sent (`/media/42`), against the server.
|
||||
static func resolve(_ path: String) -> URL? {
|
||||
guard let base = url else { return nil }
|
||||
return URL(string: path, relativeTo: base)?.absoluteURL
|
||||
}
|
||||
|
||||
/// Where the player, the badge and CarPlay talk to ipx, which is not where the page is: an
|
||||
/// address with no Cloudflare Access in front, for the API token alone (#419). Always https,
|
||||
/// since the token travels in a header.
|
||||
private static let apiKey = "ipx.api"
|
||||
static let suggestedAPI = "https://ipxapi.sdf1.net"
|
||||
|
||||
static var apiURL: URL? {
|
||||
get { UserDefaults.standard.string(forKey: apiKey).flatMap(normalise).flatMap { $0.scheme == "https" ? $0 : nil } }
|
||||
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: apiKey) }
|
||||
}
|
||||
|
||||
/// A path against the player's address when there are credentials to send there, else the
|
||||
/// page's, where the web view's cookie does the signing in.
|
||||
static func resolveForPlayer(_ path: String) -> URL? {
|
||||
guard Credentials.isSet, let base = apiURL else { return resolve(path) }
|
||||
return URL(string: path, relativeTo: base)?.absoluteURL
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user