Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d238681ec1 | |||
| 0395717c14 | |||
| 8ce68a881a | |||
| e7c59489ee | |||
| f7f4b466dc | |||
| ec8fd5dd86 | |||
| a387012c69 | |||
| a00516687a | |||
| 38ebc7b02b | |||
| 54d827f655 | |||
| e2593eaa50 | |||
| d52ecfcbd7 | |||
| 142610e8b8 | |||
| 43acc62259 | |||
| 65fdab8b13 | |||
| 4d312a87ec | |||
| ef5bdb4244 | |||
| c7f13eea2f | |||
| 7a134b810f | |||
| 79bc006a30 | |||
| 9084b61bb6 | |||
| 0cc956b7c6 | |||
| db87bc0f42 | |||
| 527777efdf | |||
| 4a26c73b82 | |||
| 98ed9b6498 | |||
| 57ab419718 | |||
| 2799704d30 | |||
| e8fd3fe9ed | |||
| 448e557272 | |||
| 36b16c7f5d | |||
| 928cbaf8f4 | |||
| 0945f3a9f4 | |||
| e9f2832e1e | |||
| d4e00be085 | |||
| 66e40e3c71 | |||
| 4f8b3d6a1d | |||
| 8ce0a4cb27 | |||
| 549ae33f06 | |||
| 9e7c93e149 | |||
| 1724346da7 | |||
| 8c5eddd783 | |||
| 469467bf04 | |||
| 95a8633877 | |||
| 00f6293b95 | |||
| bae22e553e | |||
| 7d55d99fac | |||
| a5de4ae06c | |||
| 4ed2d59311 | |||
| 2004da3459 | |||
| c6980c355d | |||
| ce221cee18 | |||
| f57f824535 | |||
| 7490a3a9ac | |||
| 5be629427a | |||
| bf785299b0 | |||
| 5967aa1e57 | |||
| 9e238bfc75 | |||
| 10b7ccd424 | |||
| d8db785681 | |||
| f9c9c2b7cc | |||
| 3cb36ab8b7 | |||
| 65b5eacdb4 | |||
| 4769a2ebe1 | |||
| 70e0341348 | |||
| e16dace9c0 | |||
| 9f56436033 | |||
| ae900b82ca | |||
| 40c92ad08d | |||
| 7796566dfc | |||
| 162665b010 | |||
| f2fde8cc75 | |||
| a573a07ed5 | |||
| 380a552913 | |||
| bdda9b3d2e | |||
| 868dd673f3 | |||
| 2930be37ad | |||
|
|
f9225f0d21 | ||
|
|
9d1492b388 | ||
| ad15ae3dfe | |||
| aedbe89654 | |||
| 321c9e014e | |||
| 73092e6ad7 | |||
| 1a3c8a6d4f | |||
| b81d44cfb6 | |||
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 | |||
| b5ff57ac0f | |||
| 35b57fa997 | |||
| 43ffafe7ac | |||
| 45cbd3a239 | |||
| 905dfa0b02 | |||
| 5f6bdbfbcb | |||
| 8849ba6bef | |||
| 1cafd8d6e3 | |||
| f09bb4a11c | |||
| 973ebdd33a | |||
| bc7491a377 | |||
| c8df148546 | |||
| c1c06229c8 |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(rtk grep *)",
|
|
||||||
"Bash(rtk read *)",
|
|
||||||
"Bash(rtk git *)"
|
|
||||||
],
|
|
||||||
"additionalDirectories": [
|
|
||||||
"/config/.claude/skills/security-audit",
|
|
||||||
"/config/security-audit-skill",
|
|
||||||
"/config/.cargo/registry"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
141
.claude/skills/ipx-prod-check/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: ipx-prod-check
|
||||||
|
description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki and its traces in Tempo, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Checking production ipx
|
||||||
|
|
||||||
|
Production logs one JSON object a line (`IPX_LOG_FORMAT=json`), which Alloy ships to Loki under
|
||||||
|
`{container="iPX"}`, and sends traces to Tempo tagged
|
||||||
|
`resource.deployment.environment.name="production"`. Anything without that tag is a daemon run by
|
||||||
|
hand, not production. Loki and Tempo publish no query port, so use the script beside this file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
Q=.claude/skills/ipx-prod-check/query.py
|
||||||
|
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
||||||
|
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
||||||
|
$Q traces '<TraceQL>' [since] # slowest first
|
||||||
|
$Q trace <trace id> # one trace as a tree, with its log lines
|
||||||
|
```
|
||||||
|
|
||||||
|
`since` is `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||||
|
|
||||||
|
## What the log carries
|
||||||
|
|
||||||
|
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
|
||||||
|
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
|
||||||
|
Lines inside a traced span, requests and scans, also carry `trace_id` and `span_id`: give the
|
||||||
|
`trace_id` to `$Q trace` to see the whole request or scan. (From 2026-09-29 16:30 UTC; before that,
|
||||||
|
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`.)
|
||||||
|
|
||||||
|
| 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::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
|
||||||
|
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
|
||||||
|
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
|
||||||
|
|
||||||
|
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
|
||||||
|
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
|
||||||
|
updated), `download_done` (bytes), `download_error` (msg, url),
|
||||||
|
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
|
||||||
|
downloaded: the healthcheck's, every 30s), `error` (msg).
|
||||||
|
|
||||||
|
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
|
||||||
|
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
|
||||||
|
|
||||||
|
Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`): it is much
|
||||||
|
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
|
||||||
|
`| json | __error__=""` drops them.
|
||||||
|
|
||||||
|
## The checks
|
||||||
|
|
||||||
|
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
|
||||||
|
a count says something happened, the lines and traces say why.
|
||||||
|
|
||||||
|
1. **Warnings and errors, grouped.** What went wrong, how often, and since when.
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (target, message) (count_over_time({container="iPX"} | json | __error__="" | level=~"WARN|ERROR" [$range]))'
|
||||||
|
```
|
||||||
|
Feed and download failures name the feed in the message; group them in the next check instead.
|
||||||
|
2. **Failing feeds and downloads.**
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"feed_error\"" | json | __error__="" [$range]))' 7d
|
||||||
|
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"download_error\"" | json | __error__="" [$range]))' 7d
|
||||||
|
```
|
||||||
|
Tell the publisher's problems from ipx's. A 404, 410, DNS failure or 503 from the feed's own
|
||||||
|
server is the publisher (worth saying, since the feed may have moved; one issue for a feed
|
||||||
|
that has been dead for days, not for a 503 once). A parse error on a feed that loads in a
|
||||||
|
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))'
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
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 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
|
||||||
|
crash or an OOM kill: check `docker inspect iPX -f '{{.State.OOMKilled}} {{.RestartCount}}'`
|
||||||
|
and the lines just before it. A pending count that only grows means downloads are not
|
||||||
|
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
|
||||||
|
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.**
|
||||||
|
```
|
||||||
|
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
|
||||||
|
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||||
|
$Q trace <id>
|
||||||
|
```
|
||||||
|
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||||
|
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
|
||||||
|
A web request over a second is worth a look; the trace shows whether the time is in the
|
||||||
|
handler or a scan it waited on.
|
||||||
|
|
||||||
|
Also check the container itself, since Loki cannot see a daemon that is not running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker ps --filter name=iPX --format '{{.Status}}'
|
||||||
|
docker logs --since 10m iPX 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
|
||||||
|
directly: ask the user if a question needs the database.
|
||||||
|
|
||||||
|
## What to do with what you find
|
||||||
|
|
||||||
|
Follow the repository's rules in CLAUDE.md: **every problem found gets a Gitea issue**, with a
|
||||||
|
closed stdin and a timeout on `tea`. Before filing, list the open issues and do not file one
|
||||||
|
twice; comment on the existing issue with the new evidence instead.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R="--login git.sdf1.net --repo rays/ipx"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues list $R --state open
|
||||||
|
t issues create $R -t "<what is wrong, as the user would notice it>" -L bug -d "<what, where, since when, how often, the query or trace id that shows it>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Put in each issue what would let someone pick it up cold: the LogQL or TraceQL that shows it, a
|
||||||
|
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
|
||||||
|
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
|
||||||
|
|
||||||
|
Finish with a short report to the user: what is healthy, what is wrong (with the issue numbers),
|
||||||
|
and anything you could not tell from logs and traces alone. Do not fix things unless asked; the
|
||||||
|
check is for finding them.
|
||||||
|
|
||||||
|
## When the checks come back empty
|
||||||
|
|
||||||
|
Check that there is data before concluding all is well: `$Q metric 'sum(count_over_time({container="iPX"} [1h]))' 1h`
|
||||||
|
should be in the hundreds or more. Nothing at all means Alloy is not shipping (it can take a few
|
||||||
|
minutes to pick up a container after a deploy), or the container is down.
|
||||||
89
.claude/skills/ipx-prod-check/query.py
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ask production's Loki or Tempo a question, from anywhere that can run docker on Tower.
|
||||||
|
|
||||||
|
query.py logs '<LogQL log query>' [since] lines, newest first
|
||||||
|
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
|
||||||
|
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
||||||
|
query.py trace <trace id> one trace's spans, as a tree
|
||||||
|
|
||||||
|
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
|
||||||
|
host, so each call runs a throwaway alpine container on the monitoring project's network.
|
||||||
|
"""
|
||||||
|
import json, subprocess, sys, time, urllib.parse
|
||||||
|
|
||||||
|
NET = "monitoring_default"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "wget", "-qO-", url],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
if out.returncode:
|
||||||
|
sys.exit(f"query failed: {out.stderr.strip() or out.stdout.strip()}\n{url}")
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds(since):
|
||||||
|
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
kind, q = sys.argv[1], sys.argv[2]
|
||||||
|
since = sys.argv[3] if len(sys.argv) > 3 else "24h"
|
||||||
|
now = time.time()
|
||||||
|
start = now - seconds(since)
|
||||||
|
enc = urllib.parse.quote
|
||||||
|
if kind == "logs":
|
||||||
|
r = fetch(f"http://loki:3100/loki/api/v1/query_range?query={enc(q)}&limit=500"
|
||||||
|
f"&start={int(start * 1e9)}&end={int(now * 1e9)}&direction=backward")
|
||||||
|
lines = [(ts, line) for s in r["data"]["result"] for ts, line in s["values"]]
|
||||||
|
for ts, line in sorted(lines, reverse=True):
|
||||||
|
print(line)
|
||||||
|
print(f"-- {len(lines)} line(s){' (limit reached)' if len(lines) >= 500 else ''}", file=sys.stderr)
|
||||||
|
elif kind == "metric":
|
||||||
|
r = fetch(f"http://loki:3100/loki/api/v1/query?query={enc(q.replace('$range', since))}&time={int(now * 1e9)}")
|
||||||
|
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
|
||||||
|
for s in rows:
|
||||||
|
labels = {k: v for k, v in s["metric"].items() if k not in ("container", "compose_project", "service_name")}
|
||||||
|
print(f"{s['value'][1]:>12} {json.dumps(labels) if labels else ''}")
|
||||||
|
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||||
|
elif kind == "traces":
|
||||||
|
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100")
|
||||||
|
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
|
||||||
|
for t in traces:
|
||||||
|
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
|
||||||
|
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {t.get('rootTraceName', '')}")
|
||||||
|
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
||||||
|
elif kind == "trace":
|
||||||
|
r = fetch(f"http://tempo:3200/api/traces/{q}")
|
||||||
|
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||||
|
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||||
|
kids = {}
|
||||||
|
for sp in spans:
|
||||||
|
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
|
||||||
|
|
||||||
|
def show(sp, depth):
|
||||||
|
ms = (int(sp["endTimeUnixNano"]) - int(sp["startTimeUnixNano"])) / 1e6
|
||||||
|
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 ''}")
|
||||||
|
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"))
|
||||||
|
# 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}")
|
||||||
|
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
|
||||||
|
show(k, depth + 1)
|
||||||
|
|
||||||
|
ids = {sp["spanId"] for sp in spans}
|
||||||
|
for root in [sp for sp in spans if sp.get("parentSpanId", "") not in ids]:
|
||||||
|
show(root, 0)
|
||||||
|
else:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
.gitignore
vendored
@@ -3,3 +3,6 @@
|
|||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
/web/dist
|
/web/dist
|
||||||
|
|
||||||
|
.claude/settings.local.json
|
||||||
|
__pycache__/
|
||||||
|
|||||||
274
CHANGELOG.md
@@ -7,14 +7,241 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default).
|
||||||
|
- `ipx add --list --category News <url>` puts a feed in the Directory for anyone to subscribe to,
|
||||||
|
and it stays there when its last subscriber leaves. Run for a feed already in the catalogue,
|
||||||
|
it lists it or sets its category.
|
||||||
|
- Feeds nobody subscribes to that are dead for a month or quiet for a year are removed, so the
|
||||||
|
Directory lists feeds worth taking.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- The database is reached through SeaORM, on the way to Postgres (issue #18); it is still the
|
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
|
||||||
same SQLite file, and nothing you see changes. A database from before 0.7 has to be opened by a
|
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
|
||||||
0.7 release first, which brings its tables up to date.
|
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
|
||||||
- A pinned item sits at the top of its list, above everything else in whatever order you sort
|
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
|
||||||
by, and moves there the moment you pin it. Sorting by the pin column itself still goes both
|
- The Directory lists feeds nobody subscribes to yet; Popular still lists what people subscribe to.
|
||||||
ways, and Currently Listening keeps its own order.
|
A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A
|
||||||
|
listed feed downloads nothing until someone subscribes.
|
||||||
|
- The Directory loads faster: it asked the database three questions per feed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting.
|
||||||
|
- Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX.
|
||||||
|
- An iPhone adding the site to its home screen finds the icon at the first address it tries.
|
||||||
|
- A feed whose server hangs no longer holds up every scan: a feed gets 30 seconds, and connecting anywhere 10.
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-09-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `IPX_LOG_FORMAT=json` logs one JSON object a line, with a request's method, route, status and time
|
||||||
|
and a scan's or download's details as fields, for Loki and the like.
|
||||||
|
- A Grafana dashboard for iPX, from its log in Loki and its traces in Tempo (`grafana/dashboard.py`).
|
||||||
|
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
|
||||||
|
requests to a collector such as Tempo.
|
||||||
|
- A feed that has no artwork of its own shows its website's icon instead.
|
||||||
|
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
|
||||||
|
while any of yours is.
|
||||||
|
- Adding a website's address subscribes to the feed that site links, instead of failing on every scan.
|
||||||
|
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
|
||||||
|
the version, for a dashboard such as Homepage.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
|
||||||
|
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list.
|
||||||
|
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
|
||||||
|
- An open page reloads the feed list only when a scan has checked something, not every minute.
|
||||||
|
- Adding an address checks it first: a feed is added, a web page adds the feed it links, and anything else is refused with the reason, instead of being added and failing on every check.
|
||||||
|
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
|
||||||
|
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
|
||||||
|
- A scan's trace shows the time a feed spends on its artwork and in the database after the fetch.
|
||||||
|
- The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of
|
||||||
|
twice, names a failure's kind in `error.type` (and its HTTP status in
|
||||||
|
`http.response.status_code`), and calls a request's time `duration_ms` instead of `ms`.
|
||||||
|
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
|
||||||
|
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
|
||||||
|
chosen one yet starts from the theme your account had.
|
||||||
|
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
|
||||||
|
orange of its needle.
|
||||||
|
- The logo follows the page: the dark version in a dark theme, the light one in a light theme.
|
||||||
|
- The browser tab's icon follows the page's light or dark mode too.
|
||||||
|
- On a phone, the top bar leaves out the logo, giving its room to the search box.
|
||||||
|
- Between releases, the version on the logo says so: 0.9.1-dev, not 0.9.0.
|
||||||
|
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
|
||||||
|
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
|
||||||
|
feed list. Hovering it shows iPX's version.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `ipx copy-db`, the one-off move from SQLite to Postgres. Going back needs only the old `state.db`.
|
||||||
|
- Starting up no longer looks for files downloaded twice before 0.6.0 read WordPress's player
|
||||||
|
links correctly; that clean-up has run.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Adding a site without `https://`, such as `cnn.com`, works; it failed on every check.
|
||||||
|
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
|
||||||
|
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
|
||||||
|
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
|
||||||
|
- A scan no longer asks a feed's website for its icon every time; it asks again when the artwork changes or you refresh the feed.
|
||||||
|
- The feed list loads several times faster: it was asking the database six questions per feed.
|
||||||
|
- Artwork a feed offers only over plain http shows on the https site too.
|
||||||
|
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
|
||||||
|
its counts, and its artwork greyed out once it has failed for a day.
|
||||||
|
- A feed whose own artwork is missing shows its website's icon instead of nothing.
|
||||||
|
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
|
||||||
|
- A feed whose website names an icon that is missing shows the site's `/favicon.ico` instead of
|
||||||
|
no artwork.
|
||||||
|
- A website's icon standing in for a feed's artwork follows the site when it changes, and a
|
||||||
|
feed that drops its own artwork gets the site's instead. Refreshing a feed checks again.
|
||||||
|
- The read button shows whether an item is read, as the pin beside it shows whether it is
|
||||||
|
pinned: a tick when read, an envelope when not. It used to show the opposite.
|
||||||
|
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
|
||||||
|
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
|
||||||
|
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
|
||||||
|
a feed that has not changed is still read again.
|
||||||
|
- A web page with some non-ASCII characters no longer hides the feed it links, or crashes looking for it.
|
||||||
|
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
|
||||||
|
a second pull meanwhile does nothing, instead of no sign at all that the check started.
|
||||||
|
- Settings no longer lists the server's download folder, which only an admin can change, on
|
||||||
|
the admin page.
|
||||||
|
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or
|
||||||
|
back to the list. The items read on the way leave the Unread tab once you close the reader.
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
|
||||||
|
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
|
||||||
|
version. The 2004 icon is still at /icon.png.
|
||||||
|
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
|
||||||
|
list of its own; an item with one of those words or phrases in its title or text is hidden
|
||||||
|
from you and is not downloaded on your account. Other people's lists never hide anything from
|
||||||
|
you.
|
||||||
|
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
|
||||||
|
elsewhere they copy the link. A link that looks like it carries your own access to a paid
|
||||||
|
feed asks before it goes anywhere.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
|
||||||
|
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
|
||||||
|
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
|
||||||
|
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
|
||||||
|
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
|
||||||
|
keep their outlines.
|
||||||
|
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
|
||||||
|
one along the far edges, and a soft sheen from the top-left corner.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
|
||||||
|
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
||||||
|
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
||||||
|
them.
|
||||||
|
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
||||||
|
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
|
||||||
|
across as it changes.
|
||||||
|
- Swiping between items on a phone no longer shows the item list through the reader. The next
|
||||||
|
or previous item sits beside the one you are reading and comes across with it, the end of the
|
||||||
|
list says so, and swiping right from the first item draws the reader off the list, dimmed
|
||||||
|
until it is uncovered. A swipe from the screen's left edge stays with the page, rather than
|
||||||
|
Safari taking it as Back.
|
||||||
|
|
||||||
|
## [0.8.4] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
|
||||||
|
goes solid when the system asks for less transparency or more contrast.
|
||||||
|
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
|
||||||
|
plays through the host's own player instead of the page's, so it keeps going when the screen
|
||||||
|
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
|
||||||
|
work as they always did. Video still plays in the page. In a browser nothing changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
|
||||||
|
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
|
||||||
|
home screen. In landscape the bars keep clear of the notch as well.
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-09-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||||
|
says where the token is kept instead: `[web] token` in config.toml.
|
||||||
|
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||||
|
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||||
|
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||||
|
docs/sso.md.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||||
|
now on have it.
|
||||||
|
|
||||||
|
## [0.8.2] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
|
||||||
|
contrast, black and white with every colour at 7:1 or more.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The themes in Settings are listed in alphabetical order.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
|
||||||
|
read comfortably. A failed-action message is easier to read in every theme.
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-09-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
|
||||||
|
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
|
||||||
|
"Downloaded" toast is only for a file on your screen.
|
||||||
|
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
|
||||||
|
- The Classic theme is listed in Settings as just "Classic".
|
||||||
|
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
|
||||||
|
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
|
||||||
|
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
|
||||||
|
drawing at desktop width.
|
||||||
|
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
|
||||||
|
- In Nordic's dark half, button and toolbar borders show.
|
||||||
|
- A zero unread count on the selected feed no longer disappears into the selection.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
|
||||||
|
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
|
||||||
|
across, everything in one go.
|
||||||
|
- The catalogue of feeds and the server settings the admin page edits are kept in the database
|
||||||
|
rather than config.toml, which keeps where things are, the torrent settings and who may sign
|
||||||
|
in. The first start takes them from config.toml and trims it, keeping the original as
|
||||||
|
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
|
||||||
|
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
|
||||||
|
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
|
||||||
|
be opened by a 0.7 release first, which brings its tables up to date.
|
||||||
|
|
||||||
## [0.7.0] - 2026-09-18
|
## [0.7.0] - 2026-09-18
|
||||||
|
|
||||||
@@ -492,17 +719,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
|
||||||
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
|
||||||
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
|
||||||
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
|
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
|
||||||
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
|
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
|
||||||
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
|
[0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
|
||||||
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
[0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
|
||||||
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
|
[0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
|
||||||
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
|
[0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
|
||||||
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
|
[0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
|
||||||
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
|
[0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
|
||||||
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
|
[0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
|
||||||
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
|
[0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
|
||||||
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0
|
[0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
|
||||||
|
[0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
|
||||||
|
[0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
|
||||||
|
[0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
|
||||||
|
[0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
|
||||||
|
[0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
|
||||||
|
[0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0
|
||||||
|
|||||||
84
CLAUDE.md
@@ -1,4 +1,4 @@
|
|||||||
# Working on ipodderx-rs
|
# Working on ipx
|
||||||
|
|
||||||
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
||||||
thing is built; this file is about working on it without repeating mistakes that have already been
|
thing is built; this file is about working on it without repeating mistakes that have already been
|
||||||
@@ -6,29 +6,50 @@ made here.
|
|||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
|
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
||||||
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
||||||
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
||||||
|
|
||||||
| | Host | In the container |
|
| | Host | In the container |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
| Image | `192.168.1.130:5000/ipx:latest` | |
|
||||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||||
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||||
|
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||||
|
|
||||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
||||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
||||||
|
|
||||||
|
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||||
|
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||||
|
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
||||||
|
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
||||||
|
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R="--login git.sdf1.net --repo rays/ipx"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||||
|
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||||
|
t issues close $R 40
|
||||||
|
```
|
||||||
|
|
||||||
|
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||||
|
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||||
|
|
||||||
|
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||||
|
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.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
|
||||||
docker logs --tail 20 iPodderX
|
docker logs --tail 20 iPX
|
||||||
```
|
```
|
||||||
|
|
||||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||||
@@ -56,8 +77,10 @@ container restarts on its own after a reboot.
|
|||||||
|
|
||||||
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||||
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
|
||||||
the shell running the command and kills the session (exit 144). This has happened more than once.
|
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
||||||
|
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.
|
||||||
|
|
||||||
## Before you touch the page
|
## Before you touch the page
|
||||||
|
|
||||||
@@ -99,6 +122,8 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
|||||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
npx tsc -p . # type-checks web/src
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
|
node tests/contrast.js # every theme's palette against WCAG AA
|
||||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -126,9 +151,17 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
||||||
and `position` columns from before accounts; two bugs came from queries still reading them
|
and `position` columns from before accounts; two bugs came from queries still reading them
|
||||||
(retention, and the entry pruner), and they were dropped in 0.5.
|
(retention, and the entry pruner), and they were dropped in 0.5.
|
||||||
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
|
* **The catalogue and the server settings are in the database, not config.toml** (issue #18):
|
||||||
|
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
|
||||||
|
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
|
||||||
|
and who gets in, the database for the rest (`assemble_config`); a change goes through
|
||||||
|
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
|
||||||
|
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
|
||||||
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
||||||
derived and never written to config.
|
derived and never in the catalogue.
|
||||||
|
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
|
||||||
|
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
|
||||||
|
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
|
||||||
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
||||||
Anyone wanting an item is enough to fetch it.
|
Anyone wanting an item is enough to fetch it.
|
||||||
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
||||||
@@ -137,6 +170,10 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
|
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
|
* 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.
|
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.
|
||||||
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
* `/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. `ipx status` checks the control socket and the database; to see the worker
|
||||||
getting through its jobs, watch for `scan complete` in the log.
|
getting through its jobs, watch for `scan complete` in the log.
|
||||||
@@ -165,18 +202,19 @@ body, where `git log` and `git blame` find it beside the change. (There was a lo
|
|||||||
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||||
|
|
||||||
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
||||||
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
`[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit
|
||||||
compare links at the bottom of the changelog.
|
`vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit,
|
||||||
|
set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
|
||||||
|
in the logo's tooltip instead of claiming to be the last release. The release that follows can
|
||||||
|
still be a minor or major one; `-dev` only says the work comes after `X.Y.Z`.
|
||||||
|
|
||||||
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
||||||
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||||
(documented in [docs/sso.md](docs/sso.md)).
|
issue left open.
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
|
||||||
Substack-style subtitles.
|
|
||||||
|
|
||||||
<!-- rtk-instructions v2 -->
|
<!-- rtk-instructions v2 -->
|
||||||
# Command output
|
# Command output
|
||||||
|
|||||||
167
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.7.0"
|
version = "0.9.2-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1830,7 +1830,11 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"jsonwebtoken",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-otlp",
|
||||||
|
"opentelemetry_sdk",
|
||||||
"opml",
|
"opml",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quick-xml 0.42.0",
|
"quick-xml 0.42.0",
|
||||||
@@ -1844,6 +1848,7 @@ dependencies = [
|
|||||||
"tower",
|
"tower",
|
||||||
"tower-http 0.7.1",
|
"tower-http 0.7.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -2007,6 +2012,22 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"signature",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -2693,6 +2714,76 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"js-sys",
|
||||||
|
"pin-project-lite",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-http"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"opentelemetry",
|
||||||
|
"reqwest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-otlp"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"httpdate",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-http",
|
||||||
|
"opentelemetry-proto",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"reqwest",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-proto"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry_sdk"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-util",
|
||||||
|
"opentelemetry",
|
||||||
|
"percent-encoding",
|
||||||
|
"portable-atomic",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opml"
|
name = "opml"
|
||||||
version = "1.1.6"
|
version = "1.1.6"
|
||||||
@@ -2908,6 +2999,29 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.14.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.14.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quanta"
|
name = "quanta"
|
||||||
version = "0.12.6"
|
version = "0.12.6"
|
||||||
@@ -3208,6 +3322,7 @@ dependencies = [
|
|||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
@@ -3786,6 +3901,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -4537,6 +4661,30 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-opentelemetry"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"opentelemetry",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-serde"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.23"
|
version = "0.3.23"
|
||||||
@@ -4547,12 +4695,15 @@ dependencies = [
|
|||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
|
"tracing-serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5183,6 +5334,20 @@ name = "zeroize"
|
|||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.7.0"
|
version = "0.9.2-dev"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,7 +12,11 @@ axum = "0.8.9"
|
|||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
|
opentelemetry = { version = "0.33", default-features = false, features = ["trace"] }
|
||||||
|
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
|
||||||
|
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||||
@@ -26,5 +30,6 @@ toml = "1.1.5"
|
|||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ipodderx-rs
|
# ipx
|
||||||
|
|
||||||
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
||||||
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
||||||
@@ -17,8 +17,8 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
||||||
manage accounts and settings.
|
manage accounts and settings.
|
||||||
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
|
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
|
||||||
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a cap
|
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a limit
|
||||||
on new downloads per scan.
|
on how many of a feed's newest episodes are downloaded.
|
||||||
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
||||||
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
||||||
an item someone has pinned.
|
an item someone has pinned.
|
||||||
@@ -30,7 +30,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
With Docker:
|
With Docker:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t ipodderx .
|
docker build -t ipx .
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
```sh
|
```sh
|
||||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
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/page-smoke.js # the page script loads without throwing
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
services:
|
services:
|
||||||
ipodderx:
|
ipx:
|
||||||
image: 192.168.1.130:5000/ipodderx:latest
|
image: 192.168.1.130:5000/ipx:latest
|
||||||
container_name: iPodderX
|
container_name: iPX
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PUID: "99"
|
PUID: "99"
|
||||||
PGID: "100"
|
PGID: "100"
|
||||||
TZ: "America/Toronto"
|
TZ: "America/Toronto"
|
||||||
IPX_LOG: "ipx=info"
|
IPX_LOG: "ipx=info"
|
||||||
|
# One JSON object a line, which Loki (through Alloy) and the Grafana dashboard read.
|
||||||
|
IPX_LOG_FORMAT: "json"
|
||||||
|
# Traces to Tempo, in the monitoring project; ipx sends none without it.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "http://192.168.1.130:4318"
|
||||||
|
# What the dashboard filters on, so a daemon run by hand for testing stays out of it.
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=production"
|
||||||
|
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
||||||
|
env_file:
|
||||||
|
- ipx.env # relative: Arcane resolves it inside its own container
|
||||||
ports:
|
ports:
|
||||||
- "8099:8099" # web UI
|
- "8099:8099" # web UI
|
||||||
- "6881:6881/tcp" # BitTorrent peers
|
- "6881:6881/tcp" # BitTorrent peers
|
||||||
- "6881:6881/udp" # DHT
|
- "6881:6881/udp" # DHT
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
|
- /mnt/fast/appdata/ipx:/config # config.toml, and the web token
|
||||||
- /mnt/user/ipodderx/:/data # state.db
|
- /mnt/user/ipx/:/data # state.db
|
||||||
- /mnt/user/ipodderx/downloads:/downloads
|
- /mnt/user/ipx/downloads:/downloads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "ipx", "status"]
|
test: ["CMD", "ipx", "status"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ before they reach the page.
|
|||||||
```sh
|
```sh
|
||||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
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/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
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ To kill it, match the binary exactly:
|
|||||||
pkill -x ipx
|
pkill -x ipx
|
||||||
```
|
```
|
||||||
|
|
||||||
`pkill -f ipx` matches the shell running the command too, and kills your own session.
|
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
|
||||||
|
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
|
||||||
|
container's processes: stop the one you started by its PID instead (`kill <pid>`).
|
||||||
|
|
||||||
## Talking to it directly
|
## Talking to it directly
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take
|
Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
|
||||||
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
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
|
||||||
|
take effect without a restart.
|
||||||
|
|
||||||
|
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
||||||
|
settings from config.toml, then rewrites config.toml without them, keeping the original beside it
|
||||||
|
as `config.toml.pre-database`. After that, feeds or those settings written into config.toml are
|
||||||
|
ignored, with a warning in the log saying so. The sections below describe them as they were
|
||||||
|
written in config.toml, which is still how a fresh install begins.
|
||||||
|
|
||||||
|
config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
|
||||||
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
||||||
|
|
||||||
| What | Where | Override |
|
| What | Where | Override |
|
||||||
@@ -11,8 +23,9 @@ effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
|||||||
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
||||||
| Downloads | `[general] download_dir` | — |
|
| Downloads | `[general] download_dir` | — |
|
||||||
|
|
||||||
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db`
|
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
|
||||||
while the daemon is stopped, or with `sqlite3 state.db .backup`.
|
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
||||||
|
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
|
||||||
|
|
||||||
## `[general]`
|
## `[general]`
|
||||||
|
|
||||||
@@ -26,18 +39,30 @@ max_total_gb = 50 # 0 = unlimited
|
|||||||
max_age_days = 30 # 0 = keep forever
|
max_age_days = 30 # 0 = keep forever
|
||||||
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
||||||
media_types = ["audio", "video"]
|
media_types = ["audio", "video"]
|
||||||
|
art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
|
||||||
|
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||||
|
|
||||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||||
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
|
||||||
|
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
||||||
|
after its first success. Admin-only from the UI.
|
||||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||||
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
||||||
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
||||||
read it. `0` disables it entirely.
|
read it. `0` disables it entirely.
|
||||||
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
||||||
Kept ones stay. `0` disables it.
|
Kept ones stay. `0` disables it.
|
||||||
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
|
* **`art_cache_mb`** — how much show and episode artwork iPX keeps on disk, in `art/` beside the
|
||||||
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
|
database, so the page loads it from iPX rather than from every publisher's server. Over this,
|
||||||
|
what has gone longest unshown goes first. `0` keeps none: artwork still comes through iPX, fetched
|
||||||
|
each time. 500 by default.
|
||||||
|
* **`max_new_per_check`** — how many of a feed's newest episodes are downloaded; older ones stay
|
||||||
|
listed to download by hand. It stops a new subscription pulling a whole back catalogue.
|
||||||
|
`0` means every episode, for an archive; set it on the feeds you want archived, since on the
|
||||||
|
global default it applies to every feed: subscribing to an OPML of 80 feeds with no limit
|
||||||
fetched 216 files and 22 GB in one scan.
|
fetched 216 files and 22 GB in one scan.
|
||||||
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
||||||
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
||||||
@@ -66,6 +91,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
|||||||
token = "" # generated and saved on first run
|
token = "" # generated and saved on first run
|
||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
|
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||||
|
access_aud = "" # the Access application's AUD tag
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
@@ -77,6 +104,9 @@ session_days = 30
|
|||||||
disables that path. See [sso.md](sso.md).
|
disables that path. See [sso.md](sso.md).
|
||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
|
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||||
|
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||||
|
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||||
@@ -88,8 +118,9 @@ itself carry a credential. Put TLS in front of it if that matters.
|
|||||||
|
|
||||||
## `[feeds.<id>]`
|
## `[feeds.<id>]`
|
||||||
|
|
||||||
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add`
|
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||||
derives it from the feed title.
|
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.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[feeds.atp]
|
[feeds.atp]
|
||||||
@@ -98,6 +129,7 @@ folder = "Accidental Tech Podcast" # default: the feed title
|
|||||||
schedule = "every 6h" # overrides [general] for this feed
|
schedule = "every 6h" # overrides [general] for this feed
|
||||||
media_types = ["audio"] # overrides [general] for this feed
|
media_types = ["audio"] # overrides [general] for this feed
|
||||||
category = "Technology" # the Directory's, if the feed names none
|
category = "Technology" # the Directory's, if the feed names none
|
||||||
|
listed = true # in the Directory with no subscribers
|
||||||
username = "ray" # HTTP basic auth
|
username = "ray" # HTTP basic auth
|
||||||
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
||||||
```
|
```
|
||||||
@@ -107,8 +139,14 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
|
|||||||
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
||||||
and are the same for everyone. See [users.md](users.md).
|
and are the same for everyone. See [users.md](users.md).
|
||||||
|
|
||||||
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and
|
**The Directory** lists every feed in the catalogue, whether or not anyone subscribes to it yet.
|
||||||
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry.
|
To put one there for others to find, `ipx add --list --category News <url>`: it subscribes no one,
|
||||||
|
and a listed feed stays when its last subscriber leaves, where another is dropped. A feed nobody
|
||||||
|
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
|
||||||
|
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
|
||||||
|
|
||||||
|
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||||
|
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -116,6 +154,10 @@ they are re-derived on every scan. Editing one in the UI promotes it to a real c
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `IPX_CONFIG` | Config file path |
|
| `IPX_CONFIG` | Config file path |
|
||||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
||||||
|
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||||
|
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||||
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
||||||
|
| `IPX_LOG_FORMAT` | `json` for one JSON object a line, with each request's and event's fields as its own (for Loki and the like); text otherwise |
|
||||||
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
|
||||||
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||||
|
|||||||
58
docs/sso.md
@@ -31,7 +31,7 @@ request it forwards through the tunnel, and ipx signs that person in.
|
|||||||
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
|
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
|
||||||
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
|
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
|
||||||
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
|
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
|
||||||
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
|
| ipx | `/mnt/fast/appdata/ipx/config.toml`, `[web]` | below |
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[web]
|
[web]
|
||||||
@@ -39,13 +39,19 @@ enabled = true
|
|||||||
bind = "0.0.0.0:8099"
|
bind = "0.0.0.0:8099"
|
||||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||||
|
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||||
|
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "/cdn-cgi/access/logout"
|
sign_out_url = "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
|
||||||
|
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||||
|
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||||
|
|
||||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||||
restart ipodderx`.
|
restart ipx`.
|
||||||
|
|
||||||
### What was missing
|
### What was missing
|
||||||
|
|
||||||
@@ -87,7 +93,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
|
|||||||
send:
|
send:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker exec iPodderX ipx user rename <old name> <email address>
|
docker exec iPX ipx user rename <old name> <email address>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Signing out
|
### Signing out
|
||||||
@@ -103,11 +109,15 @@ feeds.
|
|||||||
|
|
||||||
### The tile in Authentik's library
|
### The tile in Authentik's library
|
||||||
|
|
||||||
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
|
Authentik's library lists Authentik's own applications, and ipx signs in through the one
|
||||||
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
|
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
|
||||||
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
|
Applications → Applications → `iPX` (slug `ipodderx`): no provider, launch URL
|
||||||
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
|
`https://ipodderx.sdf1.net`, and web/logo.svg at 512px as its icon, uploaded again when the logo
|
||||||
tile. Who actually gets in is still up to the Access policy.
|
changes. The library keeps each person's list of tiles in a cache that an edit to the application
|
||||||
|
does not clear, so after one, clear it (System → Policies → Clear cache, or
|
||||||
|
`POST /api/v3/policies/all/cache_clear/`) or the old name and icon stay up. It has no policy
|
||||||
|
bindings, so everyone in Authentik sees the tile. Who actually gets in is still up to the Access
|
||||||
|
policy.
|
||||||
|
|
||||||
### Check it
|
### Check it
|
||||||
|
|
||||||
@@ -172,9 +182,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
|||||||
sides. Never list a LAN address or range: anyone there could then send
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||||
|
|
||||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||||
boundary, and is the upgrade if that ever matters.
|
a container on Tower cannot make one.
|
||||||
|
|
||||||
|
### Verifying Cloudflare's token
|
||||||
|
|
||||||
|
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||||
|
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||||
|
request, and takes the name from its `email` claim.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||||
|
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||||
|
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||||
|
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||||
|
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||||
|
the keys cannot be fetched; password and token sign-in still work then.
|
||||||
|
|
||||||
|
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||||
|
place.
|
||||||
|
|
||||||
|
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||||
|
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
|
**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 (`ipx user passwd <name>`).
|
||||||
@@ -192,7 +226,7 @@ echo -n 'newsecret' | ipx user passwd sam # change a password
|
|||||||
ipx user rm sam # remove the account
|
ipx user rm sam # remove the account
|
||||||
```
|
```
|
||||||
|
|
||||||
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
|
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
|
||||||
that read a password.
|
that read a password.
|
||||||
|
|
||||||
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
||||||
|
|||||||
174
grafana/dashboard.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""The iPX dashboard in Grafana, from Loki (the container's log, shipped by Alloy) and Tempo.
|
||||||
|
|
||||||
|
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
|
||||||
|
|
||||||
|
Grafana reads that file on its own within a minute; edits made in Grafana are refused. The panels
|
||||||
|
read the fields of ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or
|
||||||
|
ipc.rs log_event has to be matched here. `dashboard.py queries` prints each
|
||||||
|
query, to try against Loki.
|
||||||
|
"""
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
LOKI = {"type": "loki", "uid": "${loki}"}
|
||||||
|
TEMPO = {"type": "tempo", "uid": "${tempo}"}
|
||||||
|
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.
|
||||||
|
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
|
||||||
|
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
|
||||||
|
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
|
||||||
|
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
|
||||||
|
TRACES = '{resource.service.name="ipx" && resource.deployment.environment.name="production"'
|
||||||
|
|
||||||
|
|
||||||
|
def status(field):
|
||||||
|
return f'max(max_over_time({EV} | ev = "status" | unwrap {field} [10m]))'
|
||||||
|
|
||||||
|
|
||||||
|
QUERIES = {}
|
||||||
|
panels, y = [], 0
|
||||||
|
pid = 0
|
||||||
|
|
||||||
|
|
||||||
|
def panel(kind, title, w, h, x, targets, **extra):
|
||||||
|
global pid
|
||||||
|
pid += 1
|
||||||
|
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
|
||||||
|
p.update(extra)
|
||||||
|
panels.append(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def loki(expr, ref="A", legend=None, instant=False, kind=None):
|
||||||
|
QUERIES[expr] = instant
|
||||||
|
t = {"refId": ref, "datasource": LOKI, "expr": expr, "queryType": "instant" if instant else "range"}
|
||||||
|
if legend:
|
||||||
|
t["legendFormat"] = legend
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def row(title):
|
||||||
|
global y, pid
|
||||||
|
pid += 1
|
||||||
|
panels.append({"id": pid, "type": "row", "title": title, "collapsed": False,
|
||||||
|
"gridPos": {"x": 0, "y": y, "w": 24, "h": 1}, "panels": []})
|
||||||
|
y += 1
|
||||||
|
|
||||||
|
|
||||||
|
def stat(title, expr, x, unit="short", color="blue", thresholds=None, desc=None):
|
||||||
|
steps = thresholds or [{"color": color, "value": None}]
|
||||||
|
return panel("stat", title, 4, 4, x, [loki(expr, instant=True)], description=desc or "",
|
||||||
|
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
|
||||||
|
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
|
||||||
|
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
|
||||||
|
"colorMode": "value", "graphMode": "none", "textMode": "value"})
|
||||||
|
|
||||||
|
|
||||||
|
def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc=""):
|
||||||
|
custom = {"drawStyle": "bars" if bars else "line", "fillOpacity": 60 if bars else 10,
|
||||||
|
"lineWidth": 1, "showPoints": "never", "stacking": {"mode": "normal" if stack else "none"}}
|
||||||
|
return panel("timeseries", title, w, h, x, targets, description=desc,
|
||||||
|
fieldConfig={"defaults": {"unit": unit, "custom": custom}, "overrides": []},
|
||||||
|
options={"legend": {"displayMode": "list", "placement": "bottom"},
|
||||||
|
"tooltip": {"mode": "multi", "sort": "desc"}})
|
||||||
|
|
||||||
|
|
||||||
|
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
|
||||||
|
return panel("table", title, w, h, x, targets, description=desc,
|
||||||
|
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
|
||||||
|
{"id": "organize", "options": {
|
||||||
|
"excludeByName": {"Time": True, "container": True, "compose_project": True,
|
||||||
|
"service_name": True},
|
||||||
|
"renameByName": rename or {}}}],
|
||||||
|
options={"showHeader": True, "sortBy": sort or []},
|
||||||
|
fieldConfig={"defaults": {}, "overrides": []})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Now
|
||||||
|
row("Now")
|
||||||
|
stat("Feeds", status("feeds"), 0, desc="From the healthcheck's status answer, every 30 seconds.")
|
||||||
|
stat("Waiting to download", status("pending"), 4)
|
||||||
|
stat("Downloaded", status("downloaded"), 8, color="green")
|
||||||
|
stat("Feed failures", f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', 12,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}],
|
||||||
|
desc="Failed feed checks in the time range.")
|
||||||
|
stat("Download failures", f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', 16,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}])
|
||||||
|
stat("Warnings and errors", f'sum(count_over_time({BAD} [$__range])) or vector(0)', 20,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}])
|
||||||
|
y += 4
|
||||||
|
|
||||||
|
# ---- Scans
|
||||||
|
row("Scans and downloads")
|
||||||
|
ts("New items found", [loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', legend="new items")],
|
||||||
|
0, bars=True)
|
||||||
|
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
|
||||||
|
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed")],
|
||||||
|
12, bars=True)
|
||||||
|
y += 8
|
||||||
|
ts("Bytes downloaded", [loki(f'sum(sum_over_time({EV} | ev="download_done" | unwrap bytes [$__interval]))', legend="bytes")],
|
||||||
|
0, unit="bytes", bars=True)
|
||||||
|
ts("Feeds checked per scan", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked")],
|
||||||
|
12, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
|
||||||
|
y += 8
|
||||||
|
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
|
||||||
|
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
|
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
|
||||||
|
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- Web
|
||||||
|
row("Web")
|
||||||
|
ts("Requests by status", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
|
||||||
|
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.")
|
||||||
|
ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
|
||||||
|
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")
|
||||||
|
y += 8
|
||||||
|
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
|
||||||
|
0, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
|
||||||
|
table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
|
||||||
|
12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- Traces
|
||||||
|
row("Traces")
|
||||||
|
for x, title, q in [(0, "Recent traces", TRACES + "}"),
|
||||||
|
(12, "Slow traces (over 2s)", TRACES + " && duration > 2s}")]:
|
||||||
|
panel("table", title, 12, 10, x,
|
||||||
|
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
|
||||||
|
"tableType": "traces"}],
|
||||||
|
fieldConfig={"defaults": {}, "overrides": []})
|
||||||
|
y += 10
|
||||||
|
|
||||||
|
# ---- Log
|
||||||
|
row("Log")
|
||||||
|
panel("logs", "Warnings and errors", 24, 10, 0, [loki(BAD + TEXT)],
|
||||||
|
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
|
||||||
|
y += 10
|
||||||
|
panel("logs", "Log", 24, 12, 0,
|
||||||
|
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
|
||||||
|
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
|
||||||
|
description="Without the event stream's requests, not-due skips and healthcheck status calls.",
|
||||||
|
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
|
||||||
|
|
||||||
|
dash = {
|
||||||
|
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
|
||||||
|
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,
|
||||||
|
"templating": {"list": [
|
||||||
|
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
|
||||||
|
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
|
||||||
|
]},
|
||||||
|
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
|
||||||
|
"panels": panels,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sys.argv[1:] == ["queries"]:
|
||||||
|
for q, instant in QUERIES.items():
|
||||||
|
print(json.dumps([q, instant]))
|
||||||
|
else:
|
||||||
|
print(json.dumps(dash, indent=2))
|
||||||
@@ -37,6 +37,10 @@ module.exports = defineConfig({
|
|||||||
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
||||||
IPX_DATA_DIR: `${setup.root}/data`,
|
IPX_DATA_DIR: `${setup.root}/data`,
|
||||||
IPX_LOG: 'ipx=info',
|
IPX_LOG: 'ipx=info',
|
||||||
|
// Blank, whatever the shell running the suite has: the test daemon sends no traces to
|
||||||
|
// production's Tempo and never opens production's database.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||||
|
IPX_DATABASE_URL: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
215
src/access.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
|
||||||
|
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
|
||||||
|
//! address is the Docker gateway: any container there could send the header and be anyone.
|
||||||
|
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
|
||||||
|
//! takes the network out of the question.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use jsonwebtoken::jwk::JwkSet;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||||
|
|
||||||
|
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
|
||||||
|
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
|
||||||
|
/// made-up key ids cannot turn every request into a request to Cloudflare.
|
||||||
|
const REFETCH_EVERY: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Keys {
|
||||||
|
cache: Mutex<Cache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Cache {
|
||||||
|
keys: HashMap<String, DecodingKey>,
|
||||||
|
fetched: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Result<JwkSet>>,
|
||||||
|
{
|
||||||
|
let kid = decode_header(token).ok()?.kid?;
|
||||||
|
let key = match self.key(&kid) {
|
||||||
|
Some(k) => k,
|
||||||
|
None => {
|
||||||
|
if !self.may_refetch() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match fetch().await {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.key(&kid)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// RS256 only: a token that names HS256 or none is refused here, before its signature
|
||||||
|
// is looked at, rather than checked with the public key as if it were a secret.
|
||||||
|
let mut v = Validation::new(Algorithm::RS256);
|
||||||
|
v.set_audience(&[aud]);
|
||||||
|
v.set_issuer(&[format!("https://{team}")]);
|
||||||
|
v.validate_nbf = true;
|
||||||
|
match decode::<Claims>(token, &key, &v) {
|
||||||
|
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "refused a Cloudflare Access token");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self, kid: &str) -> Option<DecodingKey> {
|
||||||
|
self.cache.lock().unwrap().keys.get(kid).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the slot as it answers, so two requests at once do not both fetch.
|
||||||
|
fn may_refetch(&self) -> bool {
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||||
|
fn store(&self, set: JwkSet) {
|
||||||
|
let keys = set
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
||||||
|
.collect();
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
c.keys = keys;
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||||
|
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||||
|
client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("fetching {url}"))?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("reading the signing keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
|
||||||
|
|
||||||
|
const TEAM: &str = "team.cloudflareaccess.com";
|
||||||
|
const AUD: &str = "aud-tag";
|
||||||
|
|
||||||
|
fn jwks() -> JwkSet {
|
||||||
|
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||||
|
let mut h = Header::new(alg);
|
||||||
|
h.kid = Some("k1".into());
|
||||||
|
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||||
|
encode(&h, &claims, &k).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||||
|
let now = get_current_timestamp() as i64;
|
||||||
|
serde_json::json!({
|
||||||
|
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||||
|
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
|
||||||
|
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
|
||||||
|
|
||||||
|
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||||
|
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
keys.store(jwks());
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
|
||||||
|
|
||||||
|
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
|
||||||
|
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
|
||||||
|
|
||||||
|
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
|
||||||
|
assert_eq!(check(&keys, &expired).await, None, "expired");
|
||||||
|
|
||||||
|
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
|
||||||
|
|
||||||
|
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||||
|
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||||
|
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
||||||
|
ok.split('.').nth(1).unwrap());
|
||||||
|
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unknown_key_refetches_once_a_minute_at_most() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
||||||
|
"a key not cached yet is fetched");
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
// A made-up key id straight after: not fetched again.
|
||||||
|
let mut h = Header::new(Algorithm::RS256);
|
||||||
|
h.kid = Some("nobody".into());
|
||||||
|
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
|
||||||
|
assert_eq!(got, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/art.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
||||||
|
//! of each publisher's server for every visit, and still there when a publisher is not.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn dir() -> PathBuf {
|
||||||
|
crate::config::data_dir().join("art")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
|
||||||
|
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
|
||||||
|
// only makes the cache miss once and refill; a collision among a few thousand images is about
|
||||||
|
// one in 10^12. Move to a SHA if either ever matters.
|
||||||
|
pub fn path(url: &str) -> PathBuf {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
url.hash(&mut h);
|
||||||
|
dir().join(format!("{:016x}", h.finish()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
|
||||||
|
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
|
||||||
|
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
|
||||||
|
let body = std::fs::read(file).ok()?;
|
||||||
|
if let Ok(f) = std::fs::File::options().write(true).open(file) {
|
||||||
|
let _ = f.set_modified(std::time::SystemTime::now());
|
||||||
|
}
|
||||||
|
Some((kind, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
|
||||||
|
/// never reads half of one.
|
||||||
|
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
|
||||||
|
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
|
||||||
|
let tmp = file.with_extension("part");
|
||||||
|
std::fs::write(&tmp, body)?;
|
||||||
|
std::fs::write(file.with_extension("type"), kind)?;
|
||||||
|
std::fs::rename(&tmp, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
|
||||||
|
/// Returns how many went.
|
||||||
|
pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
||||||
|
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().is_none())
|
||||||
|
.filter_map(|p| {
|
||||||
|
let m = p.metadata().ok()?;
|
||||||
|
Some((m.modified().ok()?, m.len(), p))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
|
||||||
|
kept.sort();
|
||||||
|
let mut gone = 0;
|
||||||
|
for (_, n, p) in kept {
|
||||||
|
if total <= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(p.with_extension("type"));
|
||||||
|
if std::fs::remove_file(&p).is_ok() {
|
||||||
|
total -= n;
|
||||||
|
gone += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gone
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_least_recently_used_go_first_until_it_fits() {
|
||||||
|
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
|
||||||
|
let f = d.join(name);
|
||||||
|
write(&f, "image/png", &[0; 1000]).unwrap();
|
||||||
|
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
|
||||||
|
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
|
||||||
|
}
|
||||||
|
// Shown just now: no longer the oldest.
|
||||||
|
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
|
||||||
|
assert_eq!(trim(&d, 2000), 1);
|
||||||
|
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
|
||||||
|
assert!(d.join("old").exists() && d.join("new").exists());
|
||||||
|
assert_eq!(trim(&d, 0), 2);
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/config.rs
@@ -39,6 +39,9 @@ pub struct General {
|
|||||||
/// image in an <enclosure>, so taking everything filled the disk with artwork and
|
/// image in an <enclosure>, so taking everything filled the disk with artwork and
|
||||||
/// counted it as episodes. Empty means take anything.
|
/// counted it as episodes. Empty means take anything.
|
||||||
pub media_types: Vec<String>,
|
pub media_types: Vec<String>,
|
||||||
|
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
|
||||||
|
/// every publisher; the least recently shown goes first. 0 keeps none.
|
||||||
|
pub art_cache_mb: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
@@ -78,6 +81,14 @@ pub struct Web {
|
|||||||
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
|
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||||
|
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||||
|
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||||
|
/// host any container can send one from the gateway's address.
|
||||||
|
pub access_team: String,
|
||||||
|
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||||
|
/// leaves the signature unchecked.
|
||||||
|
pub access_aud: String,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
pub auto_create_users: bool,
|
||||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||||
@@ -96,6 +107,8 @@ impl Default for Web {
|
|||||||
token: String::new(),
|
token: String::new(),
|
||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
|
access_team: String::new(),
|
||||||
|
access_aud: String::new(),
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
sign_out_url: String::new(),
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
@@ -107,6 +120,12 @@ impl Web {
|
|||||||
pub fn binds_publicly(&self) -> bool {
|
pub fn binds_publicly(&self) -> bool {
|
||||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both halves of the Access check, or None while either is unset.
|
||||||
|
pub fn access(&self) -> Option<(&str, &str)> {
|
||||||
|
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||||
|
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -147,6 +166,10 @@ pub struct Feed {
|
|||||||
/// Preferred over `password`: name of an env var holding the password.
|
/// Preferred over `password`: name of an env var holding the password.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub password_env: Option<String>,
|
pub password_env: Option<String>,
|
||||||
|
/// Put in the Directory by an admin (`ipx 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn yes() -> bool {
|
fn yes() -> bool {
|
||||||
@@ -164,6 +187,7 @@ impl Default for General {
|
|||||||
max_age_days: 0,
|
max_age_days: 0,
|
||||||
max_new_per_check: 3,
|
max_new_per_check: 3,
|
||||||
media_types: vec!["audio".into(), "video".into()],
|
media_types: vec!["audio".into(), "video".into()],
|
||||||
|
art_cache_mb: 500,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,14 +285,90 @@ impl Config {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir)
|
|
||||||
.with_context(|| format!("creating {}", dir.display()))?;
|
|
||||||
}
|
}
|
||||||
let text = toml::to_string_pretty(self)?;
|
|
||||||
|
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||||
|
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
|
||||||
|
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
|
||||||
|
/// or decides who gets in, and stays in config.toml.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Stored {
|
||||||
|
pub schedule: String,
|
||||||
|
pub max_total_gb: f64,
|
||||||
|
pub max_age_days: u64,
|
||||||
|
pub max_new_per_check: usize,
|
||||||
|
pub media_types: Vec<String>,
|
||||||
|
/// Settings saved before it existed have none: they get the default.
|
||||||
|
#[serde(default = "art_cache_mb")]
|
||||||
|
pub art_cache_mb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn art_cache_mb() -> u64 {
|
||||||
|
General::default().art_cache_mb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[general]` keys that live in the database once it holds the configuration.
|
||||||
|
const STORED_KEYS: [&str; 6] =
|
||||||
|
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"];
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
pub fn of(cfg: &Config) -> Self {
|
||||||
|
let g = &cfg.general;
|
||||||
|
Self {
|
||||||
|
schedule: g.schedule.clone(),
|
||||||
|
max_total_gb: g.max_total_gb,
|
||||||
|
max_age_days: g.max_age_days,
|
||||||
|
max_new_per_check: g.max_new_per_check,
|
||||||
|
media_types: g.media_types.clone(),
|
||||||
|
art_cache_mb: g.art_cache_mb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(self, cfg: &mut Config) {
|
||||||
|
let g = &mut cfg.general;
|
||||||
|
g.schedule = self.schedule;
|
||||||
|
g.max_total_gb = self.max_total_gb;
|
||||||
|
g.max_age_days = self.max_age_days;
|
||||||
|
g.max_new_per_check = self.max_new_per_check;
|
||||||
|
g.media_types = self.media_types;
|
||||||
|
g.art_cache_mb = self.art_cache_mb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// config.toml as it is kept once the database holds the feeds and server settings: the same
|
||||||
|
/// file without `[feeds]` or the `[general]` keys in `Stored`.
|
||||||
|
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
|
||||||
|
let mut v = toml::Value::try_from(self)?;
|
||||||
|
if let Some(t) = v.as_table_mut() {
|
||||||
|
t.remove("feeds");
|
||||||
|
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
|
||||||
|
for k in STORED_KEYS {
|
||||||
|
g.remove(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_private(path, &toml::to_string_pretty(&v)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether config.toml still lists feeds or server settings, which the database now holds:
|
||||||
|
/// an edit there would otherwise go unnoticed.
|
||||||
|
pub fn file_holds_stored(path: &Path) -> bool {
|
||||||
|
let Ok(text) = std::fs::read_to_string(path) else { return false };
|
||||||
|
let Ok(v) = text.parse::<toml::Table>() else { return false };
|
||||||
|
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|
||||||
|
|| v.get("general")
|
||||||
|
.and_then(|g| g.as_table())
|
||||||
|
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
|
||||||
|
fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||||
|
}
|
||||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||||
// Passwords may live in here.
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -276,7 +376,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
||||||
pub fn config_path() -> PathBuf {
|
pub fn config_path() -> PathBuf {
|
||||||
@@ -370,6 +469,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
||||||
|
let cfg: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
download_dir = "/downloads"
|
||||||
|
schedule = "every 2h"
|
||||||
|
max_new_per_check = 7
|
||||||
|
media_types = ["audio"]
|
||||||
|
[web]
|
||||||
|
bind = "0.0.0.0:8099"
|
||||||
|
token = "t"
|
||||||
|
[feeds.show]
|
||||||
|
url = "http://x/show.xml"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
|
||||||
|
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
|
||||||
|
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
|
||||||
|
cfg.save_bootstrap(&path).unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(!Config::file_holds_stored(&path), "{text}");
|
||||||
|
let back: Config = toml::from_str(&text).unwrap();
|
||||||
|
assert!(back.feeds.is_empty());
|
||||||
|
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
|
||||||
|
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_a_config_and_applies_defaults() {
|
fn parses_a_config_and_applies_defaults() {
|
||||||
let cfg: Config = toml::from_str(
|
let cfg: Config = toml::from_str(
|
||||||
@@ -460,6 +589,12 @@ mod tests {
|
|||||||
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_saved_before_the_art_cache_keep_the_default() {
|
||||||
|
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
|
||||||
|
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slugs_are_readable_and_unique() {
|
fn slugs_are_readable_and_unique() {
|
||||||
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
||||||
@@ -473,7 +608,7 @@ mod tests {
|
|||||||
taken.insert("the-daily".to_string(), Feed {
|
taken.insert("the-daily".to_string(), Feed {
|
||||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||||
auto_download: true, max_new_per_check: None, username: None,
|
auto_download: true, max_new_per_check: None, username: None,
|
||||||
password: None, password_env: None, category: None,
|
password: None, password_env: None, category: None, listed: false,
|
||||||
});
|
});
|
||||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||||
}
|
}
|
||||||
@@ -494,6 +629,7 @@ mod tests {
|
|||||||
password: Some("literal".into()),
|
password: Some("literal".into()),
|
||||||
password_env: None,
|
password_env: None,
|
||||||
category: None,
|
category: None,
|
||||||
|
listed: false,
|
||||||
};
|
};
|
||||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
|
||||||
|
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
|
||||||
|
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
|
||||||
|
/// "Trump's" is by "trump".
|
||||||
|
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
|
||||||
|
// Letters and digits only, each run of anything else one space, padded so a phrase matches
|
||||||
|
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
|
||||||
|
let norm = |s: &str| {
|
||||||
|
let mut out = String::from(" ");
|
||||||
|
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
|
||||||
|
out.push_str(&w.to_lowercase());
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let hay = norm(&haystacks.join(" "));
|
||||||
|
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -387,7 +406,7 @@ mod tests {
|
|||||||
let mut f = crate::config::Feed {
|
let mut f = crate::config::Feed {
|
||||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
||||||
max_new_per_check: None, username: None, password: None, password_env: None, category: None,
|
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false,
|
||||||
};
|
};
|
||||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||||
|
|
||||||
@@ -404,4 +423,16 @@ mod tests {
|
|||||||
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
||||||
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocking_matches_whole_words_and_phrases() {
|
||||||
|
let words = vec!["AI".to_string(), "new york".to_string()];
|
||||||
|
assert!(blocked(&words, &["What AI means now"]));
|
||||||
|
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
|
||||||
|
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
|
||||||
|
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
|
||||||
|
assert!(!blocked(&words, &["New Yorkshire"]));
|
||||||
|
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
|
||||||
|
assert!(!blocked(&[], &["anything"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ pub mod enclosures {
|
|||||||
pub length: Option<i64>,
|
pub length: Option<i64>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
|
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
|
||||||
|
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
|
||||||
|
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
|
||||||
|
/// why), then 'downloading', 'done', 'error', 'reaped'.
|
||||||
#[sea_orm(column_type = "Text")]
|
#[sea_orm(column_type = "Text")]
|
||||||
pub state: String,
|
pub state: String,
|
||||||
#[sea_orm(default_value = 0)]
|
#[sea_orm(default_value = 0)]
|
||||||
@@ -228,6 +232,46 @@ pub mod entry_state {
|
|||||||
owned_by_user!();
|
owned_by_user!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
|
||||||
|
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
|
||||||
|
/// every feed they read.
|
||||||
|
pub mod blocklists {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "blocklists")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
/// JSON array of strings.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub words: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The items someone's block lists hide from them, worked out whenever a list or the feed
|
||||||
|
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
|
||||||
|
pub mod hidden {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "hidden")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
pub mod sessions {
|
pub mod sessions {
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
@@ -242,3 +286,44 @@ pub mod sessions {
|
|||||||
|
|
||||||
owned_by_user!();
|
owned_by_user!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
||||||
|
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
||||||
|
pub mod catalogue {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "catalogue")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub spec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
|
||||||
|
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
|
||||||
|
/// configuration has moved in (issue #18).
|
||||||
|
pub mod settings {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "settings")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|||||||
388
src/feed.rs
@@ -11,6 +11,8 @@ pub struct ParsedFeed {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub ttl_mins: Option<u64>,
|
pub ttl_mins: Option<u64>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
|
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
||||||
|
pub site: Option<String>,
|
||||||
/// The channel's first `<itunes:category>`, for the Directory's chips.
|
/// The channel's first `<itunes:category>`, for the Directory's chips.
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub entries: Vec<Entry>,
|
pub entries: Vec<Entry>,
|
||||||
@@ -51,42 +53,72 @@ pub enum Fetched {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conditional GET. reqwest handles gzip and redirects; the original's hand-rolled
|
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
||||||
/// CONNECT/socket.ssl proxy path is gone -- `system-proxy` reads http_proxy/https_proxy.
|
/// with no limit one hung server held every scan for as long as it did. Dreamwidth answered 504
|
||||||
|
/// after 60-67 s for a day, and each scan took 70-80 s instead of 15 (#108). A feed is small;
|
||||||
|
/// downloads, which are not, have no such limit.
|
||||||
|
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
||||||
|
/// each hop is seen. The second value is where the feed now is when every hop said so for good
|
||||||
|
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
|
||||||
|
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
|
||||||
|
/// `system-proxy` reads http_proxy/https_proxy.
|
||||||
|
#[tracing::instrument(skip_all, fields(url = %cfg.url))]
|
||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
cfg: &FeedCfg,
|
cfg: &FeedCfg,
|
||||||
etag: Option<&str>,
|
etag: Option<&str>,
|
||||||
last_modified: Option<&str>,
|
last_modified: Option<&str>,
|
||||||
) -> Result<Fetched> {
|
) -> Result<(Fetched, Option<String>)> {
|
||||||
let mut req = client.get(&cfg.url);
|
let start = reqwest::Url::parse(&cfg.url).context("the feed's address")?;
|
||||||
|
let mut url = start.clone();
|
||||||
|
let mut permanent = true;
|
||||||
|
for _ in 0..10 {
|
||||||
|
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
|
||||||
if let Some(tag) = etag {
|
if let Some(tag) = etag {
|
||||||
req = req.header(IF_NONE_MATCH, tag);
|
req = req.header(IF_NONE_MATCH, tag);
|
||||||
}
|
}
|
||||||
if let Some(lm) = last_modified {
|
if let Some(lm) = last_modified {
|
||||||
req = req.header(IF_MODIFIED_SINCE, lm);
|
req = req.header(IF_MODIFIED_SINCE, lm);
|
||||||
}
|
}
|
||||||
if let Some(user) = &cfg.username {
|
// The feed's own host only: a redirect elsewhere must not be handed the password.
|
||||||
|
if let Some(user) = &cfg.username
|
||||||
|
&& url.host_str() == start.host_str()
|
||||||
|
{
|
||||||
req = req.basic_auth(user, cfg.password());
|
req = req.basic_auth(user, cfg.password());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = req.send().await.context("connecting")?;
|
let resp = req.send().await.context("connecting")?;
|
||||||
if resp.status() == StatusCode::NOT_MODIFIED {
|
|
||||||
return Ok(Fetched::NotModified);
|
|
||||||
}
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
|
||||||
|
let to = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LOCATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or_else(|| anyhow!("HTTP {status} without a Location to go to"))?;
|
||||||
|
url = url.join(to).with_context(|| format!("redirected to {to:?}, which is not an address"))?;
|
||||||
|
permanent &= matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let moved = (permanent && url != start).then(|| url.to_string());
|
||||||
|
if status == StatusCode::NOT_MODIFIED {
|
||||||
|
return Ok((Fetched::NotModified, moved));
|
||||||
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
||||||
return Err(anyhow!("HTTP {status}"));
|
return Err(anyhow!("HTTP {status}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = |h: reqwest::header::HeaderName| {
|
let header = |h: reqwest::header::HeaderName| {
|
||||||
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
||||||
};
|
};
|
||||||
let etag = header(ETAG);
|
let etag = header(ETAG);
|
||||||
let last_modified = header(LAST_MODIFIED);
|
let last_modified = header(LAST_MODIFIED);
|
||||||
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
||||||
Ok(Fetched::Body { bytes, etag, last_modified })
|
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||||
|
}
|
||||||
|
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
||||||
|
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||||
@@ -96,6 +128,37 @@ pub struct Failure {
|
|||||||
pub new_url: Option<String>,
|
pub new_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as
|
||||||
|
/// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the
|
||||||
|
/// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind.
|
||||||
|
pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
||||||
|
let low = msg.to_ascii_lowercase();
|
||||||
|
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().ok());
|
||||||
|
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
|
||||||
|
return (c.to_string(), Some(c));
|
||||||
|
}
|
||||||
|
let kind = if low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated") {
|
||||||
|
"dns"
|
||||||
|
} else if low.contains("too many redirects") {
|
||||||
|
"redirect_loop"
|
||||||
|
} else if low.contains("timed out") || low.contains("timeout") {
|
||||||
|
"timeout"
|
||||||
|
} else if low.contains("certificate") || low.contains("tls") {
|
||||||
|
"tls"
|
||||||
|
} else if low.contains("got a web page") {
|
||||||
|
"not_a_feed"
|
||||||
|
} else if low.contains("the site sent ") {
|
||||||
|
"site_message"
|
||||||
|
} else if low.contains("connect") {
|
||||||
|
"connect"
|
||||||
|
} else if low.contains("pars") {
|
||||||
|
"parse"
|
||||||
|
} else {
|
||||||
|
"other"
|
||||||
|
};
|
||||||
|
(kind.into(), None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
||||||
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
||||||
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
||||||
@@ -185,11 +248,20 @@ pub fn is_patreon_creator(url: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
|
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
|
||||||
/// feed, since the token alone says whose it is.
|
/// feed, since the token alone says whose it is. An address with no scheme is https: 'cnn.com'
|
||||||
|
/// was stored as typed and every check failed with "relative URL without a base" (#101).
|
||||||
pub fn expand_input(input: &str) -> String {
|
pub fn expand_input(input: &str) -> String {
|
||||||
let s = input.trim();
|
let s = input.trim();
|
||||||
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||||
if token { format!("https://www.patreon.com/rss?auth={s}") } else { s.to_owned() }
|
if token {
|
||||||
|
format!("https://www.patreon.com/rss?auth={s}")
|
||||||
|
} else if let Some(rest) = s.strip_prefix("//") {
|
||||||
|
format!("https://{rest}")
|
||||||
|
} else if !s.contains("://") {
|
||||||
|
format!("https://{s}")
|
||||||
|
} else {
|
||||||
|
s.to_owned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
||||||
@@ -210,7 +282,7 @@ pub async fn patreon_shows(
|
|||||||
) -> Result<(Option<String>, Vec<(String, String)>)> {
|
) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||||
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
||||||
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
||||||
let mut resp = client.get(url).send().await.context("connecting")?;
|
let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -330,7 +402,9 @@ fn plain_text(bytes: &[u8]) -> Option<String> {
|
|||||||
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
||||||
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||||
let text = String::from_utf8_lossy(bytes);
|
let text = String::from_utf8_lossy(bytes);
|
||||||
let lower = text.to_lowercase();
|
// ASCII only: to_lowercase changes some characters' length (U+0130 grows a byte), and the
|
||||||
|
// offsets found in the lowered copy then sliced the original off a char boundary.
|
||||||
|
let lower = text.to_ascii_lowercase();
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
while let Some(rel) = lower[pos..].find("<link") {
|
while let Some(rel) = lower[pos..].find("<link") {
|
||||||
let start = pos + rel;
|
let start = pos + rel;
|
||||||
@@ -349,6 +423,124 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What someone asked to add, as a feed: the address itself when it is a feed or an OPML list,
|
||||||
|
/// otherwise the feed its web page links as its own, otherwise an error and nothing is added.
|
||||||
|
/// A page that linked no feed used to be added as it was and failed on every check, called a
|
||||||
|
/// feed that moved (#102); cnn.com is one.
|
||||||
|
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
||||||
|
let read = |u: String| async move {
|
||||||
|
let resp = client
|
||||||
|
.get(&u)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("connecting")?;
|
||||||
|
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
|
||||||
|
let base = resp.url().clone();
|
||||||
|
anyhow::Ok((base, resp.bytes().await.context("reading")?))
|
||||||
|
};
|
||||||
|
let is_feed = |b: &[u8]| is_opml(b) || parse(b).is_ok();
|
||||||
|
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
|
||||||
|
if is_feed(&body) {
|
||||||
|
return Ok(url.to_owned());
|
||||||
|
}
|
||||||
|
if !looks_like_html(&body) {
|
||||||
|
let why = parse(&body).err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||||
|
anyhow::bail!("{url} is not a feed: {why}");
|
||||||
|
}
|
||||||
|
let Some(href) = alternate_feed_link(&body) else {
|
||||||
|
anyhow::bail!("{url} is a web page that links no feed, so there is nothing to subscribe to");
|
||||||
|
};
|
||||||
|
let linked = base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
|
||||||
|
let (_, feed) = read(linked.to_string()).await.with_context(|| format!("{url} links {linked} as its feed, but"))?;
|
||||||
|
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
|
||||||
|
Ok(linked.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork for a feed that has none: the icon its site's page names, or else the site's
|
||||||
|
/// `/favicon.ico`. None if neither is there.
|
||||||
|
#[tracing::instrument(skip_all, fields(site = site))]
|
||||||
|
pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
||||||
|
let timeout = std::time::Duration::from_secs(20);
|
||||||
|
let resp = client.get(site).timeout(timeout).send().await.ok()?;
|
||||||
|
// Relative to where the page ended up, not where it was asked for: a site that redirects
|
||||||
|
// to /en/ would otherwise have its icon looked for in the wrong place.
|
||||||
|
let base = resp.url().clone();
|
||||||
|
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
|
||||||
|
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
|
||||||
|
if resp.status().is_success()
|
||||||
|
&& let Ok(page) = resp.bytes().await
|
||||||
|
&& let Some(href) = page_icon(&page)
|
||||||
|
&& let Ok(url) = base.join(&href)
|
||||||
|
&& is_image(client, url.as_str()).await
|
||||||
|
{
|
||||||
|
return Some(url.into());
|
||||||
|
}
|
||||||
|
let ico = base.join("/favicon.ico").ok()?;
|
||||||
|
is_image(client, ico.as_str()).await.then(|| ico.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork's address on https when its host serves it there, else as it was. The page is https
|
||||||
|
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
|
||||||
|
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
|
||||||
|
/// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN
|
||||||
|
/// presents another name's certificate (#110).
|
||||||
|
pub async fn prefer_https(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
known: &mut std::collections::HashMap<String, bool>,
|
||||||
|
) -> String {
|
||||||
|
let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() };
|
||||||
|
let host = rest.split('/').next().unwrap_or("").to_owned();
|
||||||
|
let secure = format!("https://{rest}");
|
||||||
|
let ok = match known.get(&host) {
|
||||||
|
Some(ok) => *ok,
|
||||||
|
None => {
|
||||||
|
let ok = is_image(client, &secure).await;
|
||||||
|
known.insert(host, ok);
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ok { secure } else { url.to_owned() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` answers with an image. A site with no favicon often answers 200 with its home
|
||||||
|
/// page, which is not an icon.
|
||||||
|
pub async fn is_image(client: &reqwest::Client, url: &str) -> bool {
|
||||||
|
let timeout = std::time::Duration::from_secs(20);
|
||||||
|
let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false };
|
||||||
|
resp.status().is_success()
|
||||||
|
&& resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|t| t.starts_with("image/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The icon a web page names in its `<link>` tags, the larger apple-touch-icon first: a plain
|
||||||
|
/// `icon` is often 16 pixels, which blurs at the size the list draws artwork.
|
||||||
|
fn page_icon(bytes: &[u8]) -> Option<String> {
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
// ASCII only, so byte offsets in the lowered copy stay valid in the original.
|
||||||
|
let lower = text.to_ascii_lowercase();
|
||||||
|
let (mut touch, mut icon) = (None, None);
|
||||||
|
let mut pos = 0;
|
||||||
|
while let Some(at) = lower[pos..].find("<link") {
|
||||||
|
let start = pos + at;
|
||||||
|
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
|
||||||
|
pos = end + 1;
|
||||||
|
let tag = &text[start..end];
|
||||||
|
let Some(rel) = tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
|
||||||
|
let rels: Vec<&str> = rel.split_whitespace().collect();
|
||||||
|
if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) {
|
||||||
|
touch = tag_attr(tag, "href");
|
||||||
|
} else if icon.is_none() && rels.contains(&"icon") {
|
||||||
|
icon = tag_attr(tag, "href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
touch.or(icon).filter(|h| !h.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
||||||
fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||||
let key = format!("{name}=");
|
let key = format!("{name}=");
|
||||||
@@ -538,6 +730,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.and_then(|i| i.image())
|
.and_then(|i| i.image())
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
||||||
|
site: non_empty(Some(ch.link().trim())),
|
||||||
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
||||||
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
||||||
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
||||||
@@ -603,6 +796,12 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
title: title_text(Some(feed.title().as_str())),
|
title: title_text(Some(feed.title().as_str())),
|
||||||
ttl_mins: None,
|
ttl_mins: None,
|
||||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||||
|
site: feed
|
||||||
|
.links()
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.rel() == "alternate")
|
||||||
|
.map(|l| l.href().trim().to_owned())
|
||||||
|
.filter(|h| !h.is_empty()),
|
||||||
category: None,
|
category: None,
|
||||||
entries,
|
entries,
|
||||||
}
|
}
|
||||||
@@ -698,10 +897,51 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
|||||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||||
non_empty(content)
|
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||||
.filter(|c| !starts_mid_tag(c))
|
Some(c) => Some(match subtitle(&c, description) {
|
||||||
.or_else(|| non_empty(description))
|
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||||
.or_else(|| non_empty(content))
|
None => c,
|
||||||
|
}),
|
||||||
|
None => non_empty(description).or_else(|| non_empty(content)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||||
|
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||||
|
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||||
|
/// description found in the body is not shown twice.
|
||||||
|
///
|
||||||
|
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||||
|
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||||
|
/// `entries` column.
|
||||||
|
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||||
|
let d = title_text(description)?;
|
||||||
|
if d.contains('<') || d.chars().count() > 300 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||||
|
// description ends in "…" or "[...]".
|
||||||
|
let words = |s: &str| {
|
||||||
|
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||||
|
};
|
||||||
|
let want = words(&d);
|
||||||
|
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||||
|
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||||
|
fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in html.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||||
@@ -770,6 +1010,54 @@ fn parse_date(s: &str) -> Option<i64> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
|
||||||
|
/// to /tmp, /new is the feed.
|
||||||
|
async fn redirecting_server() -> String {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||||
|
let req = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
||||||
|
let body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
|
||||||
|
let resp = match path.as_str() {
|
||||||
|
"/old" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/tmp" => "HTTP/1.1 302 Found\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/chain" => "HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/loop" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
|
||||||
|
};
|
||||||
|
let _ = sock.write_all(resp.as_bytes()).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
|
||||||
|
let base = redirecting_server().await;
|
||||||
|
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
|
||||||
|
let get = |path: &str| {
|
||||||
|
let cfg: crate::config::Feed = serde_json::from_value(serde_json::json!({ "url": format!("{base}{path}") })).unwrap();
|
||||||
|
let client = client.clone();
|
||||||
|
async move { super::fetch(&client, &cfg, None, None).await }
|
||||||
|
};
|
||||||
|
let (got, moved) = get("/old").await.unwrap();
|
||||||
|
assert!(matches!(got, super::Fetched::Body { .. }));
|
||||||
|
assert_eq!(moved, Some(format!("{base}/new")));
|
||||||
|
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
|
||||||
|
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
|
||||||
|
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
||||||
|
let looped = get("/loop").await.err().unwrap().to_string();
|
||||||
|
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -841,12 +1129,36 @@ mod tests {
|
|||||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||||
|
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||||
|
Some("<p>The post.</p>"),
|
||||||
|
"a description with markup in it is notes, not a subtitle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||||
|
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||||
|
);
|
||||||
|
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||||
|
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||||
|
for d in ["This week we talk about tape, drums and a very long list.",
|
||||||
|
"This week we talk about tape, drums…",
|
||||||
|
"This week we talk about\ntape [...]"] {
|
||||||
|
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||||
|
}
|
||||||
|
let long = "word ".repeat(80);
|
||||||
|
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
||||||
|
"a long description is notes, not a subtitle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feed_level_explicit_overrides_entries() {
|
fn feed_level_explicit_overrides_entries() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
@@ -863,6 +1175,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_named_by_kind_for_the_log() {
|
||||||
|
let k = |m: &str| failure_kind(m);
|
||||||
|
assert_eq!(k("HTTP 404 Not Found"), ("404".into(), Some(404)));
|
||||||
|
assert_eq!(k("HTTP 503 Service Unavailable"), ("503".into(), Some(503)));
|
||||||
|
assert_eq!(
|
||||||
|
k("connecting: error following redirect for url (https://www.toddstashwick.com/): too many redirects").0,
|
||||||
|
"redirect_loop"
|
||||||
|
);
|
||||||
|
assert_eq!(k("connecting: dns error: failed to lookup address information").0, "dns");
|
||||||
|
assert_eq!(k("operation timed out").0, "timeout");
|
||||||
|
assert_eq!(k("something new").0, "other");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1028,6 +1354,10 @@ mod tests {
|
|||||||
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
||||||
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
||||||
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
|
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
|
||||||
|
assert_eq!(expand_input("http://example.com/rss"), "http://example.com/rss");
|
||||||
|
assert_eq!(expand_input(" cnn.com "), "https://cnn.com");
|
||||||
|
assert_eq!(expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
|
||||||
|
assert_eq!(expand_input("//example.com/rss"), "https://example.com/rss");
|
||||||
|
|
||||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
||||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
||||||
@@ -1092,6 +1422,24 @@ mod tests {
|
|||||||
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
|
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_link_after_non_ascii_text_is_found() {
|
||||||
|
// U+0130 lowercases to three bytes from two, which once shifted every offset after it.
|
||||||
|
let page = "<html><title>\u{130}stanbul \u{130}\u{130}</title>\
|
||||||
|
<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/feed.xml\">";
|
||||||
|
assert_eq!(alternate_feed_link(page.as_bytes()).as_deref(), Some("/feed.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_icon_prefers_the_touch_icon() {
|
||||||
|
let page = br#"<head><link rel="stylesheet" href="/a.css">
|
||||||
|
<link rel="shortcut icon" href="/fav.ico">
|
||||||
|
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
|
||||||
|
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
|
||||||
|
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
|
||||||
|
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_items_picture_comes_from_the_most_deliberate_source() {
|
fn an_items_picture_comes_from_the_most_deliberate_source() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
|||||||
116
src/ipc.rs
@@ -15,6 +15,9 @@ pub enum Event {
|
|||||||
FeedSkip { feed: String, reason: String },
|
FeedSkip { feed: String, reason: String },
|
||||||
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
||||||
FeedError { feed: String, msg: String },
|
FeedError { feed: String, msg: String },
|
||||||
|
/// The feed answered from a new address after redirects that all said it moved for good,
|
||||||
|
/// and the catalogue now has that address (#115).
|
||||||
|
FeedMoved { feed: String, from: String, to: String },
|
||||||
Progress {
|
Progress {
|
||||||
feed: String,
|
feed: String,
|
||||||
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
||||||
@@ -50,6 +53,7 @@ impl Event {
|
|||||||
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
|
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
|
||||||
),
|
),
|
||||||
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
|
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
|
||||||
|
Event::FeedMoved { feed, from, to } => format!("{feed}: moved for good from {from} to {to}; following it"),
|
||||||
Event::Progress { file, done, total, .. } => match total {
|
Event::Progress { file, done, total, .. } => match total {
|
||||||
Some(t) if *t > 0 => format!(
|
Some(t) if *t > 0 => format!(
|
||||||
" {file}: {:.1}% ({:.1}/{:.1} MB)",
|
" {file}: {:.1}% ({:.1}/{:.1} MB)",
|
||||||
@@ -69,7 +73,7 @@ impl Event {
|
|||||||
*bytes as f64 / 1_048_576.0
|
*bytes as f64 / 1_048_576.0
|
||||||
),
|
),
|
||||||
Event::Status { feeds, pending, downloaded } => {
|
Event::Status { feeds, pending, downloaded } => {
|
||||||
format!("{feeds} feeds, {pending} pending, {downloaded} downloaded")
|
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
|
||||||
}
|
}
|
||||||
Event::Error { msg } => format!("error: {msg}"),
|
Event::Error { msg } => format!("error: {msg}"),
|
||||||
// Noise in a terminal; a UI still gets them on the socket.
|
// Noise in a terminal; a UI still gets them on the socket.
|
||||||
@@ -88,6 +92,11 @@ pub enum Command {
|
|||||||
feed: Option<String>,
|
feed: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
|
||||||
|
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
|
||||||
|
/// Empty is every feed, as the schedule and the CLI mean it.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
feeds: Vec<String>,
|
||||||
},
|
},
|
||||||
Reap {
|
Reap {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -126,38 +135,8 @@ impl Emitter {
|
|||||||
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
|
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
|
||||||
// line per feed per tick for "not due yet" would push everything worth reading
|
// line per feed per tick for "not due yet" would push everything worth reading
|
||||||
// out of the buffer within a few minutes.
|
// out of the buffer within a few minutes.
|
||||||
let routine = match &e {
|
log_event(&e, self.tx.is_some());
|
||||||
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
|
||||||
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
|
||||||
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
let bad = matches!(
|
|
||||||
&e,
|
|
||||||
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
|
|
||||||
);
|
|
||||||
if let Some(line) = e.human() {
|
|
||||||
let line = line.trim();
|
|
||||||
if bad {
|
|
||||||
tracing::warn!(target: "ipx::scan", "{line}");
|
|
||||||
} else if routine {
|
|
||||||
tracing::debug!(target: "ipx::scan", "{line}");
|
|
||||||
} else {
|
|
||||||
tracing::info!(target: "ipx::scan", "{line}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tx) = &self.tx {
|
if let Some(tx) = &self.tx {
|
||||||
// The outbound half of the protocol, as it goes on the wire. Progress is the
|
|
||||||
// high-volume one, so it sits at debug.
|
|
||||||
if let Ok(json) = serde_json::to_string(&e) {
|
|
||||||
if matches!(e, Event::Progress { .. }) {
|
|
||||||
tracing::debug!(target: "ipx::io", "<- {json}");
|
|
||||||
} else {
|
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// An error here only means nobody is listening yet.
|
// An error here only means nobody is listening yet.
|
||||||
let _ = tx.send(e.clone());
|
let _ = tx.send(e.clone());
|
||||||
}
|
}
|
||||||
@@ -167,6 +146,63 @@ impl Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
|
||||||
|
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
|
||||||
|
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
|
||||||
|
/// Level by how much it matters: with 80-odd feeds in an OPML subscription, a line per feed per
|
||||||
|
/// tick for "not due yet" would push everything worth reading out of the log view in minutes,
|
||||||
|
/// and Progress fires on every whole percent. `wire` also logs the event as it goes on the
|
||||||
|
/// socket, at debug: the admin page's Daemon I/O tab shows it, production's log leaves it out.
|
||||||
|
fn log_event(e: &Event, wire: bool) {
|
||||||
|
let json = serde_json::to_string(e).unwrap_or_default();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
|
||||||
|
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
|
||||||
|
let n = |k: &str| v.get(k).and_then(|x| x.as_u64());
|
||||||
|
let (kind, code) = match e {
|
||||||
|
Event::FeedError { msg, .. } | Event::DownloadError { msg, .. } | Event::Error { msg } => {
|
||||||
|
let (k, c) = crate::feed::failure_kind(msg);
|
||||||
|
(Some(k), c)
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
let routine = match e {
|
||||||
|
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
||||||
|
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
||||||
|
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
macro_rules! line {
|
||||||
|
($level:ident, $target:literal, $text:expr) => {
|
||||||
|
tracing::$level!(
|
||||||
|
target: $target,
|
||||||
|
ev = s("ev"), feed = s("feed"), msg = s("msg"), url = s("url"), reason = s("reason"), from = s("from"), to = s("to"),
|
||||||
|
new = n("new"), downloaded = n("downloaded"), failed = n("failed"),
|
||||||
|
torrents = n("torrents"), bytes = n("bytes"), feeds = n("feeds"),
|
||||||
|
pending = n("pending"), enclosure = n("enclosure"), files = n("files"),
|
||||||
|
"error.type" = kind, "http.response.status_code" = code,
|
||||||
|
"{}", $text
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// The healthcheck's answer, every 30s: 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}"));
|
||||||
|
}
|
||||||
|
if wire {
|
||||||
|
tracing::debug!(target: "ipx::io", "<- {json}");
|
||||||
|
}
|
||||||
|
let text = e.human().map(|l| l.trim().to_owned()).unwrap_or_else(|| json.clone());
|
||||||
|
if kind.is_some() {
|
||||||
|
line!(warn, "ipx::scan", text)
|
||||||
|
} else if routine {
|
||||||
|
line!(debug, "ipx::scan", text)
|
||||||
|
} else {
|
||||||
|
line!(info, "ipx::scan", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// True when something is already listening -- i.e. a daemon owns this socket.
|
/// True when something is already listening -- i.e. a daemon owns this socket.
|
||||||
pub async fn daemon_is_live(path: &Path) -> bool {
|
pub async fn daemon_is_live(path: &Path) -> bool {
|
||||||
UnixStream::connect(path).await.is_ok()
|
UnixStream::connect(path).await.is_ok()
|
||||||
@@ -252,9 +288,7 @@ async fn handle(
|
|||||||
Ok(Command::Status) => {
|
Ok(Command::Status) => {
|
||||||
tracing::info!(target: "ipx::io", "-> {line}");
|
tracing::info!(target: "ipx::io", "-> {line}");
|
||||||
let ev = status().await;
|
let ev = status().await;
|
||||||
if let Ok(json) = serde_json::to_string(&ev) {
|
log_event(&ev, true);
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
|
||||||
}
|
|
||||||
let _ = reply.send(ev).await;
|
let _ = reply.send(ev).await;
|
||||||
}
|
}
|
||||||
Ok(cmd) => {
|
Ok(cmd) => {
|
||||||
@@ -298,13 +332,21 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
||||||
|
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||||
|
let wire = serde_json::to_value(&e).unwrap();
|
||||||
|
assert_eq!((wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()), (Some("feed_moved"), Some("http://a/f"), Some("https://a/f")));
|
||||||
|
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commands_parse_from_the_wire_form() {
|
fn commands_parse_from_the_wire_form() {
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: None, force: false }));
|
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp"));
|
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Reap { dry_run: true }));
|
assert!(matches!(got, Command::Reap { dry_run: true }));
|
||||||
|
|||||||
748
src/main.rs
@@ -57,6 +57,10 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
report.reconciled += 1;
|
report.reconciled += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for id in db.prune_abandoned_failures().await? {
|
||||||
|
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
|
||||||
|
}
|
||||||
|
|
||||||
let candidates = db.reap_candidates().await?;
|
let candidates = db.reap_candidates().await?;
|
||||||
|
|
||||||
if cfg.general.max_age_days > 0 {
|
if cfg.general.max_age_days > 0 {
|
||||||
|
|||||||
436
src/web.rs
@@ -1,9 +1,10 @@
|
|||||||
//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
|
//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, Query, Request, State},
|
extract::{MatchedPath, Path, Query, Request, State},
|
||||||
http::{StatusCode, header},
|
http::{StatusCode, header},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::{
|
response::{
|
||||||
@@ -16,7 +17,6 @@ use serde::Deserialize;
|
|||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use tower_http::services::ServeFile;
|
use tower_http::services::ServeFile;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
@@ -28,9 +28,10 @@ const COOKIE: &str = "ipx_token";
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebState {
|
pub struct WebState {
|
||||||
pub ctx: Arc<Ctx>,
|
pub ctx: Arc<Ctx>,
|
||||||
pub config_path: PathBuf,
|
|
||||||
pub cmds: mpsc::Sender<Command>,
|
pub cmds: mpsc::Sender<Command>,
|
||||||
pub events: broadcast::Sender<Event>,
|
pub events: broadcast::Sender<Event>,
|
||||||
|
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||||
|
pub access: Arc<crate::access::Keys>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
@@ -52,6 +53,7 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/fetch", post(fetch_now))
|
.route("/api/fetch", post(fetch_now))
|
||||||
.route("/api/opml", get(export_opml).post(import_opml))
|
.route("/api/opml", get(export_opml).post(import_opml))
|
||||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||||
|
.route("/api/status", get(status))
|
||||||
.route("/api/popular", get(get_popular))
|
.route("/api/popular", get(get_popular))
|
||||||
.route("/api/directory", get(get_directory))
|
.route("/api/directory", get(get_directory))
|
||||||
.route("/api/popular/{id}", post(subscribe_popular))
|
.route("/api/popular/{id}", post(subscribe_popular))
|
||||||
@@ -62,18 +64,25 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/admin", get(admin_page))
|
.route("/admin", get(admin_page))
|
||||||
.route("/admin.js", get(admin_js))
|
.route("/admin.js", get(admin_js))
|
||||||
.route("/media/{id}", get(media))
|
.route("/media/{id}", get(media))
|
||||||
|
.route("/api/art", get(art))
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||||
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
||||||
.route("/login", get(login_page))
|
.route("/login", get(login_page))
|
||||||
.route("/api/login", post(login))
|
.route("/api/login", post(login))
|
||||||
.route("/icon.png", get(icon))
|
.route("/icon.png", get(icon))
|
||||||
|
.route("/logo.svg", get(logo))
|
||||||
|
.route("/logo-dark.svg", get(logo_dark))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
.route("/favicon.png", get(favicon))
|
.route("/favicon.png", get(favicon))
|
||||||
|
.route("/favicon-dark.png", get(favicon_dark))
|
||||||
.route("/apple-touch-icon.png", get(touch_icon))
|
.route("/apple-touch-icon.png", get(touch_icon))
|
||||||
|
// iOS asks for this one first when the site is added to a home screen (#109).
|
||||||
|
.route("/apple-touch-icon-precomposed.png", get(touch_icon))
|
||||||
.route("/app.js", get(app_js))
|
.route("/app.js", get(app_js))
|
||||||
.route("/app.css", get(app_css))
|
.route("/app.css", get(app_css))
|
||||||
.route("/login.js", get(login_js))
|
.route("/login.js", get(login_js))
|
||||||
.route("/inter.woff2", get(inter))
|
.route("/inter.woff2", get(inter))
|
||||||
|
.route_layer(middleware::from_fn(name_span))
|
||||||
.layer(middleware::from_fn(access_log))
|
.layer(middleware::from_fn(access_log))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
@@ -101,7 +110,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let token = cfg.web.token.clone();
|
let token = cfg.web.token.clone();
|
||||||
|
|
||||||
// 1. A header, but only from a hop we were told to believe.
|
// 1. A header, but only from a hop we were told to believe.
|
||||||
let vouched = vouched_name(&cfg, &req);
|
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
|
||||||
|
|
||||||
let mut set_cookie: Option<String> = None;
|
let mut set_cookie: Option<String> = None;
|
||||||
let mut user = None;
|
let mut user = None;
|
||||||
@@ -203,20 +212,31 @@ struct Proxied(bool);
|
|||||||
|
|
||||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||||
/// be whoever they liked.
|
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||||
let peer = req
|
///
|
||||||
.extensions()
|
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
/// the future unsendable, as a body is not `Sync`.
|
||||||
.map(|c| c.0.ip().to_string())
|
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
.unwrap_or_default();
|
|
||||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
req.headers()
|
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||||
.get(&cfg.web.trusted_header)
|
let Some((team, aud)) = cfg.web.access() else {
|
||||||
.and_then(|v| v.to_str().ok())
|
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||||
.and_then(crate::auth::name_from_header)
|
};
|
||||||
|
// The plain header still has to be there, as it is what switches this path on for a
|
||||||
|
// request; who it names is the token's to say.
|
||||||
|
header(&cfg.web.trusted_header)?;
|
||||||
|
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||||
|
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(req: &Request) -> String {
|
||||||
|
req.extensions()
|
||||||
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||||
|
.map(|c| c.0.ip().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
@@ -237,7 +257,11 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cookie(req: &Request, name: &str) -> Option<String> {
|
fn cookie(req: &Request, name: &str) -> Option<String> {
|
||||||
req.headers()
|
headers_cookie(req.headers(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||||
|
headers
|
||||||
.get(header::COOKIE)
|
.get(header::COOKIE)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|c| {
|
.and_then(|c| {
|
||||||
@@ -320,30 +344,28 @@ async fn me(
|
|||||||
) -> Json<serde_json::Value> {
|
) -> Json<serde_json::Value> {
|
||||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||||
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct MePatch {
|
struct MePatch {
|
||||||
theme: String,
|
/// Words that hide an item in every feed you read.
|
||||||
mode: String,
|
blocked: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the theme to the account, so it follows the person rather than the browser.
|
/// Saves the block list for every feed. The theme is not the account's: each browser keeps its
|
||||||
|
/// own, in a cookie (issue #69).
|
||||||
async fn patch_me(
|
async fn patch_me(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<MePatch>,
|
Json(body): Json<MePatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
if let Some(words) = body.blocked {
|
||||||
// into the page's <html> tag, which is where index() puts it.
|
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
|
||||||
if !theme_ok(&body.theme, &body.mode) {
|
|
||||||
return Err(ApiError::bad_request("not a theme"));
|
|
||||||
}
|
}
|
||||||
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +498,7 @@ async fn remove_user(
|
|||||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||||
/// the form only made it look as if they were not.
|
/// the form only made it look as if they were not.
|
||||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||||
@@ -504,13 +526,26 @@ async fn app_css() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cookie the page keeps its theme in, `<theme>.<mode>`, written by theme.ts.
|
||||||
|
const THEME_COOKIE: &str = "ipx_theme";
|
||||||
|
|
||||||
|
/// The theme this browser chose, from its cookie: per device, so a phone and a desktop signed in
|
||||||
|
/// as the same person can each have their own (issue #69). A browser without one yet gets the
|
||||||
|
/// theme the account kept from before, which theme.ts then writes into the cookie.
|
||||||
|
async fn page_theme(state: &WebState, user: &crate::db::User, headers: &HeaderMap) -> (Option<String>, Option<String>) {
|
||||||
|
if let Some((t, m)) = headers_cookie(headers, THEME_COOKIE).as_deref().and_then(|v| v.split_once('.')) {
|
||||||
|
return (Some(t.to_owned()), Some(m.to_owned()));
|
||||||
|
}
|
||||||
|
state.ctx.db.theme(user.id).await.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// The admin page and its script go to admins only: not just hidden from everyone else, never
|
/// The admin page and its script go to admins only: not just hidden from everyone else, never
|
||||||
/// sent. Anyone else asking for the page is sent back to the app.
|
/// sent. Anyone else asking for the page is sent back to the app.
|
||||||
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response {
|
async fn admin_page(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> Response {
|
||||||
if !user.is_admin {
|
if !user.is_admin {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
let theme = page_theme(&state, &user, &headers).await;
|
||||||
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
||||||
}
|
}
|
||||||
@@ -530,8 +565,25 @@ fn script(js: &'static str) -> impl IntoResponse {
|
|||||||
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
|
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
/// The logo the pages show, served once rather than inlined into each. The sign-in page shows
|
||||||
/// sign-in page shows it, so it sits outside the auth layer with /login.
|
/// it, so it sits outside the auth layer with /login.
|
||||||
|
async fn logo() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_str!("../web/logo.svg"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// logo.svg recoloured for a dark page. The pages pick one or the other by their light or dark
|
||||||
|
/// mode.
|
||||||
|
async fn logo_dark() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_str!("../web/logo-dark.svg"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
|
||||||
async fn icon() -> impl IntoResponse {
|
async fn icon() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
@@ -539,9 +591,8 @@ async fn icon() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
|
/// web/logo.svg at 128px. /favicon.ico is the same PNG, for a browser that asks for that on its
|
||||||
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
|
/// own; behind the auth layer it answered 401, and the tab stayed blank.
|
||||||
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
|
|
||||||
async fn favicon() -> impl IntoResponse {
|
async fn favicon() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
@@ -549,8 +600,16 @@ async fn favicon() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
|
/// web/logo-dark.svg at 128px, the tab's icon while the page is dark.
|
||||||
/// one is on white.
|
async fn favicon_dark() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_bytes!("../web/favicon-dark.png").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
|
||||||
|
/// a transparent icon's background black and cuts its own corners.
|
||||||
async fn touch_icon() -> impl IntoResponse {
|
async fn touch_icon() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
@@ -580,11 +639,14 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
|
|||||||
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
|
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
|
||||||
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
|
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
|
||||||
const ADMIN_LINK: &str = "<a id=admin ";
|
const ADMIN_LINK: &str = "<a id=admin ";
|
||||||
|
// The logo's tooltip. Filled in here, not by build.mjs, because build.rs does not rerun when only
|
||||||
|
// Cargo.toml's version changes, and the page would go on naming the last release.
|
||||||
|
const VERSION_SLOT: &str = "iPX {version}";
|
||||||
|
|
||||||
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
||||||
/// script instead showed it for a moment on every load, until /api/me answered.
|
/// script instead showed it for a moment on every load, until /api/me answered.
|
||||||
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
|
async fn index(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> impl IntoResponse {
|
||||||
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
let theme = page_theme(&state, &user, &headers).await;
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +656,7 @@ const HTML_TAG: &str = "<html lang=en>";
|
|||||||
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
|
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
|
||||||
/// showed it for a moment on every load, until /api/me answered (issue #29).
|
/// showed it for a moment on every load, until /api/me answered (issue #29).
|
||||||
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
|
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
|
||||||
let mut page = with_theme(INDEX, theme);
|
let mut page = with_theme(INDEX, theme).replacen(VERSION_SLOT, concat!("iPX ", env!("CARGO_PKG_VERSION")), 1);
|
||||||
if !admin
|
if !admin
|
||||||
&& let Some(at) = page.find(ADMIN_LINK)
|
&& let Some(at) = page.find(ADMIN_LINK)
|
||||||
&& let Some(len) = page[at..].find("</a>")
|
&& let Some(len) = page[at..].find("</a>")
|
||||||
@@ -628,6 +690,8 @@ struct FeedRow {
|
|||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
feed_category: Option<String>,
|
feed_category: Option<String>,
|
||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
|
/// Your words that hide an item of this feed, beside your list for every feed.
|
||||||
|
blocked: Vec<String>,
|
||||||
allow_explicit: bool,
|
allow_explicit: bool,
|
||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
max_new_per_check: Option<usize>,
|
max_new_per_check: Option<usize>,
|
||||||
@@ -669,6 +733,12 @@ async fn feeds(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<FeedRow>>, ApiError> {
|
) -> Result<Json<Vec<FeedRow>>, ApiError> {
|
||||||
|
Ok(Json(feed_rows(&state, &user, None).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed list as this person sees it, or with `only` the one row for that feed: what a live
|
||||||
|
/// update sends after the feed changes, so the page redraws a row instead of reloading the list.
|
||||||
|
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
|
||||||
let cfg = state.ctx.cfg();
|
let cfg = state.ctx.cfg();
|
||||||
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
||||||
// What comes back is only the part of it this person subscribes to.
|
// What comes back is only the part of it this person subscribes to.
|
||||||
@@ -682,15 +752,16 @@ async fn feeds(
|
|||||||
.collect();
|
.collect();
|
||||||
let counts = state.ctx.db.subscriber_counts().await?;
|
let counts = state.ctx.db.subscriber_counts().await?;
|
||||||
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
|
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
|
||||||
|
let mut listed = state.ctx.db.feed_list(user.id, only).await?;
|
||||||
let mut out = Vec::with_capacity(mine.len());
|
let mut out = Vec::with_capacity(mine.len());
|
||||||
for sub in &subs {
|
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
|
||||||
let (id, feed) = (&sub.id, &sub.cfg);
|
let (id, feed) = (&sub.id, &sub.cfg);
|
||||||
// In a group, what you have not set on the feed comes from your settings on the group,
|
// In a group, what you have not set on the feed comes from your settings on the group,
|
||||||
// the same fallback the scanner uses (`Db::subscribers`).
|
// the same fallback the scanner uses (`Db::subscribers`).
|
||||||
let up = feed.group.as_deref().and_then(|g| mine.get(g));
|
let up = feed.group.as_deref().and_then(|g| mine.get(g));
|
||||||
let Some(mine) = mine.get(id) else { continue };
|
let Some(mine) = mine.get(id) else { continue };
|
||||||
let s = state.ctx.db.feed_summary(id).await?;
|
// A feed not scanned yet has no row: blank, as feed_summary gave it.
|
||||||
let st = state.ctx.db.http_state(id).await?;
|
let crate::db::FeedListing { summary: s, ttl_mins, blocked, unread } = listed.remove(id).unwrap_or_default();
|
||||||
out.push(FeedRow {
|
out.push(FeedRow {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
url: feed.url.clone(),
|
url: feed.url.clone(),
|
||||||
@@ -704,6 +775,7 @@ async fn feeds(
|
|||||||
.clone()
|
.clone()
|
||||||
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
||||||
.unwrap_or_else(|| feed.keywords.clone()),
|
.unwrap_or_else(|| feed.keywords.clone()),
|
||||||
|
blocked,
|
||||||
allow_explicit: mine
|
allow_explicit: mine
|
||||||
.allow_explicit
|
.allow_explicit
|
||||||
.or(up.and_then(|u| u.allow_explicit))
|
.or(up.and_then(|u| u.allow_explicit))
|
||||||
@@ -726,11 +798,11 @@ async fn feeds(
|
|||||||
.schedule
|
.schedule
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(crate::config::parse_interval),
|
.and_then(crate::config::parse_interval),
|
||||||
every_mins: crate::due_after(&cfg, feed, st.ttl_mins) / 60,
|
every_mins: crate::due_after(&cfg, feed, ttl_mins, None, 0) / 60,
|
||||||
last_checked: s.last_checked,
|
last_checked: s.last_checked,
|
||||||
next_check: s
|
next_check: s
|
||||||
.last_checked
|
.last_checked
|
||||||
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64),
|
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, t) as i64),
|
||||||
failing: s
|
failing: s
|
||||||
.error_since
|
.error_since
|
||||||
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
|
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
|
||||||
@@ -740,12 +812,12 @@ async fn feeds(
|
|||||||
last_error: s.last_error,
|
last_error: s.last_error,
|
||||||
entries: s.entries,
|
entries: s.entries,
|
||||||
downloaded: s.downloaded,
|
downloaded: s.downloaded,
|
||||||
unread: state.ctx.db.unread_count(user.id, id).await?,
|
unread,
|
||||||
subscribers: counts.get(id).copied().unwrap_or(0),
|
subscribers: counts.get(id).copied().unwrap_or(0),
|
||||||
pinned: pinned.contains(id),
|
pinned: pinned.contains(id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Json(out))
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- popular on this server ----
|
// ---- popular on this server ----
|
||||||
@@ -800,8 +872,13 @@ struct PopularRow {
|
|||||||
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||||
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
||||||
/// feeds inside it and never itself: both lists are for finding a show.
|
/// feeds inside it and never itself: both lists are for finding a show.
|
||||||
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
/// The catalogue as others may see it. `everything` is the Directory: every feed, with or
|
||||||
|
/// without subscribers, so the ones an admin listed show before anyone takes them. Without it,
|
||||||
|
/// Popular: only what people subscribe to.
|
||||||
|
async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec<PopularRow>> {
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
|
// One pass for every feed's title, artwork and category, not three queries a feed.
|
||||||
|
let mut listed = db.feed_list(user_id, None).await?;
|
||||||
let mine: std::collections::HashSet<String> =
|
let mine: std::collections::HashSet<String> =
|
||||||
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let counts = db.subscriber_counts().await?;
|
let counts = db.subscriber_counts().await?;
|
||||||
@@ -816,14 +893,14 @@ async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
|||||||
let n = counts.get(&s.id).copied().unwrap_or(0);
|
let n = counts.get(&s.id).copied().unwrap_or(0);
|
||||||
// A feed inside an OPML that looks private is as private as the OPML.
|
// A feed inside an OPML that looks private is as private as the OPML.
|
||||||
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
|
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
|
||||||
if n == 0
|
if (n == 0 && !everything)
|
||||||
|| is_folder.contains(s.id.as_str())
|
|| is_folder.contains(s.id.as_str())
|
||||||
|| looks_private(&s.cfg)
|
|| looks_private(&s.cfg)
|
||||||
|| folder.is_some_and(|f| looks_private(f))
|
|| folder.is_some_and(|f| looks_private(f))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sum = db.feed_summary(&s.id).await?;
|
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
|
||||||
let subscribed = mine.contains(&s.id);
|
let subscribed = mine.contains(&s.id);
|
||||||
out.push(PopularRow {
|
out.push(PopularRow {
|
||||||
id: s.id.clone(),
|
id: s.id.clone(),
|
||||||
@@ -844,7 +921,7 @@ async fn get_popular(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id).await?;
|
let mut rows = popular(&state, user.id, false).await?;
|
||||||
rows.truncate(10);
|
rows.truncate(10);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -854,7 +931,7 @@ async fn get_directory(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id).await?;
|
let mut rows = popular(&state, user.id, true).await?;
|
||||||
rows.sort_by_key(sort_name);
|
rows.sort_by_key(sort_name);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -870,10 +947,12 @@ async fn subscribe_popular(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
if !popular(&state, user.id).await?.iter().any(|p| p.id == id) {
|
if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) {
|
||||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||||
}
|
}
|
||||||
state.ctx.db.subscribe(user.id, &id).await?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
|
// A listed feed nobody took was checked once a day at most: read it now, not in an hour.
|
||||||
|
scan_soon(&state, Some(id.clone())).await;
|
||||||
Ok(Json(serde_json::json!({ "id": id })))
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +1048,13 @@ mod tests {
|
|||||||
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
|
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_logo_names_the_version() {
|
||||||
|
// If the minifier drifts from VERSION_SLOT, replacen matches nothing and says nothing.
|
||||||
|
let page = page_for(false, (None, None));
|
||||||
|
assert!(!page.contains(VERSION_SLOT) && page.contains(concat!("iPX ", env!("CARGO_PKG_VERSION"))));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_page_arrives_in_the_theme_the_account_chose() {
|
fn the_page_arrives_in_the_theme_the_account_chose() {
|
||||||
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
|
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
|
||||||
@@ -996,6 +1082,7 @@ mod tests {
|
|||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
category: None,
|
category: None,
|
||||||
|
listed: false,
|
||||||
};
|
};
|
||||||
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
||||||
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
||||||
@@ -1042,7 +1129,7 @@ mod tests {
|
|||||||
crate::config::Feed {
|
crate::config::Feed {
|
||||||
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||||
auto_download: true, max_new_per_check: None, username: None,
|
auto_download: true, max_new_per_check: None, username: None,
|
||||||
password: None, password_env: None, category: None,
|
password: None, password_env: None, category: None, listed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,12 +1233,7 @@ async fn entry_page(
|
|||||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
||||||
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
// Currently Listening keeps its own order, pinned or not: it is what you are part-way through.
|
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
|
||||||
let order = crate::db::order_sql(
|
|
||||||
page.sort.as_deref().unwrap_or("published"),
|
|
||||||
page.dir.as_deref().unwrap_or("desc"),
|
|
||||||
filter != crate::db::Filter::InProgress,
|
|
||||||
);
|
|
||||||
let mut rows =
|
let mut rows =
|
||||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
||||||
let mut sanitizer = feed_sanitizer();
|
let mut sanitizer = feed_sanitizer();
|
||||||
@@ -1216,7 +1298,11 @@ async fn add_feed(
|
|||||||
Json(body): Json<NewFeed>,
|
Json(body): Json<NewFeed>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let mut cfg = (*state.ctx.cfg()).clone();
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
let url = crate::feed::expand_input(&body.url);
|
// Before the duplicate check, so a site's page finds the feed someone already has. What is
|
||||||
|
// not a feed and links none is refused here, with why, and nothing is added.
|
||||||
|
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
|
||||||
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
||||||
// second copy on disk, just another name against the same feed.
|
// second copy on disk, just another name against the same feed.
|
||||||
if let Some(existing) = crate::subscriptions(&state.ctx).await?
|
if let Some(existing) = crate::subscriptions(&state.ctx).await?
|
||||||
@@ -1234,8 +1320,7 @@ async fn add_feed(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
state.ctx.db.subscribe(user.id, &id).await?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
||||||
scan_soon(&state, Some(id.clone())).await;
|
scan_soon(&state, Some(id.clone())).await;
|
||||||
@@ -1247,7 +1332,7 @@ async fn add_feed(
|
|||||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||||
let force = feed.is_some();
|
let force = feed.is_some();
|
||||||
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
|
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");
|
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1352,7 @@ struct FeedPatch {
|
|||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
category: Option<Option<String>>,
|
category: Option<Option<String>>,
|
||||||
keywords: Option<Vec<String>>,
|
keywords: Option<Vec<String>>,
|
||||||
|
blocked: Option<Vec<String>>,
|
||||||
allow_explicit: Option<bool>,
|
allow_explicit: Option<bool>,
|
||||||
auto_download: Option<bool>,
|
auto_download: Option<bool>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
@@ -1274,6 +1360,21 @@ struct FeedPatch {
|
|||||||
pinned: Option<bool>,
|
pinned: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
|
||||||
|
/// looked for in every item of every feed the list covers.
|
||||||
|
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
|
||||||
|
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
|
||||||
|
out.push(w.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
|
||||||
|
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
where
|
where
|
||||||
T: Deserialize<'de>,
|
T: Deserialize<'de>,
|
||||||
@@ -1322,6 +1423,9 @@ async fn patch_feed(
|
|||||||
if touched {
|
if touched {
|
||||||
state.ctx.db.set_subscription(user.id, &mine).await?;
|
state.ctx.db.set_subscription(user.id, &mine).await?;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = body.blocked.clone() {
|
||||||
|
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rest describes the feed itself -- where its files land, its address, when it is
|
// The rest describes the feed itself -- where its files land, its address, when it is
|
||||||
@@ -1383,8 +1487,7 @@ async fn patch_feed(
|
|||||||
if let Some(v) = body.category {
|
if let Some(v) = body.category {
|
||||||
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
if url_changed {
|
if url_changed {
|
||||||
// Refreshing a rotated auth token is the common case; entries and download history
|
// Refreshing a rotated auth token is the common case; entries and download history
|
||||||
// are keyed by feed id, so they survive the change.
|
// are keyed by feed id, so they survive the change.
|
||||||
@@ -1412,16 +1515,19 @@ async fn remove_feed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
||||||
// someone subscribes again they do not pull the back catalogue a second time.
|
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
||||||
|
// admin listed stays in the Directory, for the next person.
|
||||||
let mut cfg = (*state.ctx.cfg()).clone();
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
|
if cfg.feeds.get(&id).is_some_and(|f| f.listed) {
|
||||||
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
if cfg.feeds.remove(&id).is_none() {
|
if cfg.feeds.remove(&id).is_none() {
|
||||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||||
// read unless you unsubscribe from the OPML itself.
|
// read unless you unsubscribe from the OPML itself.
|
||||||
state.ctx.db.drop_managed(&id).await?;
|
state.ctx.db.drop_managed(&id).await?;
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
crate::retire_group(&state.ctx, &id).await?;
|
crate::retire_group(&state.ctx, &id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1437,7 +1543,7 @@ async fn set_flags(
|
|||||||
Path((feed_id, guid)): Path<(String, String)>,
|
Path((feed_id, guid)): Path<(String, String)>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<Flags>,
|
Json(body): Json<Flags>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<Json<Option<FeedRow>>, ApiError> {
|
||||||
use crate::db::EntryFlag;
|
use crate::db::EntryFlag;
|
||||||
if let Some(v) = body.read {
|
if let Some(v) = body.read {
|
||||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
|
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
|
||||||
@@ -1445,7 +1551,9 @@ async fn set_flags(
|
|||||||
if let Some(v) = body.flagged {
|
if let Some(v) = body.flagged {
|
||||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
|
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
// The feed's row with its new unread count, for the page to put in place of the old one
|
||||||
|
// rather than reloading the whole list after every item read.
|
||||||
|
Ok(Json(feed_rows(&state, &user, Some(&feed_id)).await?.into_iter().next()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the
|
/// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the
|
||||||
@@ -1537,34 +1645,69 @@ struct FetchBody {
|
|||||||
|
|
||||||
async fn fetch_now(
|
async fn fetch_now(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
Json(body): Json<FetchBody>,
|
Json(body): Json<FetchBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
|
||||||
|
let feeds = if body.feed.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
|
||||||
|
};
|
||||||
|
if body.feed.is_none() && feeds.is_empty() {
|
||||||
|
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Fetch { feed: body.feed, force: body.force })
|
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same broadcast the socket clients read, as server-sent events.
|
/// The same broadcast the socket clients read, as server-sent events.
|
||||||
async fn events(State(state): State<WebState>) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
|
async fn events(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
|
||||||
|
let rx = state.events.subscribe();
|
||||||
// A client that falls behind skips what it missed rather than being cut off.
|
// A client that falls behind skips what it missed rather than being cut off.
|
||||||
let stream = futures_util::stream::unfold(state.events.subscribe(), |mut rx| async move {
|
let stream = futures_util::stream::unfold((rx, state, user), |(mut rx, state, user)| async move {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(ev) => {
|
Ok(ev) => {
|
||||||
if let Ok(data) = serde_json::to_string(&ev) {
|
let Ok(data) = serde_json::to_string(&ev) else { continue };
|
||||||
let ev = Ok::<_, std::convert::Infallible>(SseEvent::default().data(data));
|
let mut out = vec![Ok::<_, std::convert::Infallible>(SseEvent::default().data(data))];
|
||||||
return Some((ev, rx));
|
// A feed that changed goes out as this person's row for it, which the page
|
||||||
|
// puts in place of the old one: it reloaded the whole list after each.
|
||||||
|
if let Some(feed) = changed_feed(&ev)
|
||||||
|
&& let Ok(rows) = feed_rows(&state, &user, Some(feed)).await
|
||||||
|
&& let Some(row) = rows.first()
|
||||||
|
&& let Ok(data) = serde_json::to_string(&serde_json::json!({ "ev": "feed_row", "row": row }))
|
||||||
|
{
|
||||||
|
out.push(Ok(SseEvent::default().data(data)));
|
||||||
}
|
}
|
||||||
|
return Some((futures_util::stream::iter(out), (rx, state, user)));
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||||
Err(broadcast::error::RecvError::Closed) => return None,
|
Err(broadcast::error::RecvError::Closed) => return None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
|
Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed an event changed what the list shows of: its counts, error or last check. Not the
|
||||||
|
/// routine skip of a feed not due, dozens a minute that change nothing.
|
||||||
|
fn changed_feed(ev: &Event) -> Option<&str> {
|
||||||
|
match ev {
|
||||||
|
Event::FeedDone { feed, .. }
|
||||||
|
| Event::FeedError { feed, .. }
|
||||||
|
| Event::FeedMoved { feed, .. }
|
||||||
|
| Event::DownloadDone { feed, .. } => Some(feed),
|
||||||
|
Event::FeedSkip { feed, reason } if !reason.starts_with("not due") => Some(feed),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio, served by ServeFile so Range requests work and the player can seek.
|
/// Audio, served by ServeFile so Range requests work and the player can seek.
|
||||||
@@ -1585,6 +1728,65 @@ async fn media(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ArtQuery {
|
||||||
|
u: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork, from iPX: the page asks here for every image a feed or item names, 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
|
||||||
|
/// 5 MB, so the route cannot be pointed at anything else. It began as a way round http-only
|
||||||
|
/// artwork on the https page (#90).
|
||||||
|
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
|
||||||
|
const MAX: usize = 5 << 20;
|
||||||
|
let web = q.u.starts_with("http://") || q.u.starts_with("https://");
|
||||||
|
if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
|
||||||
|
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
|
||||||
|
}
|
||||||
|
let keep = state.ctx.cfg().general.art_cache_mb > 0;
|
||||||
|
let file = crate::art::path(&q.u);
|
||||||
|
if keep && let Some((kind, body)) = crate::art::read(&file) {
|
||||||
|
return art_response(kind, body);
|
||||||
|
}
|
||||||
|
let got = async {
|
||||||
|
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?;
|
||||||
|
let kind = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
||||||
|
anyhow::ensure!(kind.starts_with("image/"), "not an image: {kind}");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
while let Some(c) = r.chunk().await? {
|
||||||
|
body.extend_from_slice(&c);
|
||||||
|
anyhow::ensure!(body.len() <= MAX, "larger than {MAX} bytes");
|
||||||
|
}
|
||||||
|
anyhow::Ok((kind, body))
|
||||||
|
};
|
||||||
|
match got.await {
|
||||||
|
Ok((kind, body)) => {
|
||||||
|
if keep && let Err(e) = crate::art::write(&file, &kind, &body) {
|
||||||
|
tracing::debug!(error = %e, "could not keep the artwork");
|
||||||
|
}
|
||||||
|
art_response(kind, body)
|
||||||
|
}
|
||||||
|
Err(e) => (StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An image from someone else's server, served from iPX's own address: it must stay an image.
|
||||||
|
/// An SVG opened on its own would otherwise run its script as iPX's page, with its cookies.
|
||||||
|
fn art_response(kind: String, body: Vec<u8>) -> Response {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, kind),
|
||||||
|
(header::CACHE_CONTROL, "private, max-age=2592000".into()),
|
||||||
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".into()),
|
||||||
|
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'; sandbox".into()),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Position {
|
struct Position {
|
||||||
secs: i64,
|
secs: i64,
|
||||||
@@ -1718,13 +1920,29 @@ async fn import_opml(
|
|||||||
// file arrives as text, is read here, and is gone when the request ends.
|
// file arrives as text, is read here, and is gone when the request ends.
|
||||||
let doc = opml::OPML::from_str(&body.xml)
|
let doc = opml::OPML::from_str(&body.xml)
|
||||||
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
|
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
|
||||||
let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id).await?;
|
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
|
||||||
if added > 0 {
|
if added > 0 {
|
||||||
scan_soon(&state, None).await;
|
scan_soon(&state, None).await;
|
||||||
}
|
}
|
||||||
Ok(Json(serde_json::json!({ "added": added, "already": already })))
|
Ok(Json(serde_json::json!({ "added": added, "already": already })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The numbers `ipx 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::status(&state.ctx).await {
|
||||||
|
Event::Status { feeds, pending, downloaded } => Json(serde_json::json!({
|
||||||
|
"feeds": feeds,
|
||||||
|
"pending": pending,
|
||||||
|
"downloaded": downloaded,
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
Event::Error { msg } => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct Settings {
|
struct Settings {
|
||||||
schedule: String,
|
schedule: String,
|
||||||
@@ -1734,6 +1952,7 @@ struct Settings {
|
|||||||
download_dir: String,
|
download_dir: String,
|
||||||
max_total_gb: f64,
|
max_total_gb: f64,
|
||||||
max_age_days: u64,
|
max_age_days: u64,
|
||||||
|
art_cache_mb: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||||
@@ -1746,6 +1965,7 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
|||||||
download_dir: cfg.general.download_dir.display().to_string(),
|
download_dir: cfg.general.download_dir.display().to_string(),
|
||||||
max_total_gb: cfg.general.max_total_gb,
|
max_total_gb: cfg.general.max_total_gb,
|
||||||
max_age_days: cfg.general.max_age_days,
|
max_age_days: cfg.general.max_age_days,
|
||||||
|
art_cache_mb: cfg.general.art_cache_mb,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1756,6 +1976,7 @@ struct SettingsPatch {
|
|||||||
media_types: Option<Vec<String>>,
|
media_types: Option<Vec<String>>,
|
||||||
max_total_gb: Option<f64>,
|
max_total_gb: Option<f64>,
|
||||||
max_age_days: Option<u64>,
|
max_age_days: Option<u64>,
|
||||||
|
art_cache_mb: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn patch_settings(
|
async fn patch_settings(
|
||||||
@@ -1792,8 +2013,10 @@ async fn patch_settings(
|
|||||||
if let Some(v) = body.max_age_days {
|
if let Some(v) = body.max_age_days {
|
||||||
cfg.general.max_age_days = v;
|
cfg.general.max_age_days = v;
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
if let Some(v) = body.art_cache_mb {
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
cfg.general.art_cache_mb = v;
|
||||||
|
}
|
||||||
|
state.ctx.store_cfg(cfg).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1825,6 +2048,22 @@ async fn logs(user: crate::db::User, Query(q): Query<LogQuery>) -> Result<Json<L
|
|||||||
Ok(Json(LogPage { lines, latest }))
|
Ok(Json(LogPage { lines, latest }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// OpenTelemetry span itself: recording `otel.name` is ignored once the span has started.
|
||||||
|
async fn name_span(route: MatchedPath, req: Request, next: Next) -> Response {
|
||||||
|
use opentelemetry::trace::TraceContextExt;
|
||||||
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
|
let span = tracing::Span::current();
|
||||||
|
span.context().span().update_name(format!("{} {}", req.method(), route.as_str()));
|
||||||
|
span.record("http.route", route.as_str());
|
||||||
|
let mut resp = next.run(req).await;
|
||||||
|
// For access_log, which runs outside routing and cannot see it otherwise.
|
||||||
|
resp.extensions_mut().insert(route);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
/// One line per HTTP request, so the web side shows up in the same log as the daemon.
|
/// One line per HTTP request, so the web side shows up in the same log as the daemon.
|
||||||
///
|
///
|
||||||
/// The log view polls `/api/logs`, so logging that path would generate a line per poll
|
/// The log view polls `/api/logs`, so logging that path would generate a line per poll
|
||||||
@@ -1833,15 +2072,36 @@ async fn access_log(req: Request, next: Next) -> Response {
|
|||||||
let path = req.uri().path().to_owned();
|
let path = req.uri().path().to_owned();
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
let quiet = path.starts_with("/api/logs");
|
let quiet = path.starts_with("/api/logs");
|
||||||
let started = std::time::Instant::now();
|
// No trace for the event stream either: an open page asks for it again and again, and Tempo
|
||||||
let resp = next.run(req).await;
|
// would fill with nothing else.
|
||||||
if !quiet {
|
let span = if quiet || path == "/api/events" {
|
||||||
let ms = started.elapsed().as_millis();
|
tracing::Span::none()
|
||||||
let status = resp.status().as_u16();
|
|
||||||
if resp.status().is_success() || resp.status().is_redirection() {
|
|
||||||
tracing::info!(target: "ipx::http", "{method} {path} -> {status} in {ms}ms");
|
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(target: "ipx::http", "{method} {path} -> {status} in {ms}ms");
|
tracing::info_span!(
|
||||||
|
target: "ipx::http",
|
||||||
|
"http",
|
||||||
|
otel.name = %format!("{method} {path}"),
|
||||||
|
http.request.method = %method,
|
||||||
|
url.path = %path,
|
||||||
|
http.route = tracing::field::Empty,
|
||||||
|
http.response.status_code = tracing::field::Empty,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let resp = tracing::Instrument::instrument(next.run(req), span.clone()).await;
|
||||||
|
span.record("http.response.status_code", resp.status().as_u16());
|
||||||
|
if !quiet {
|
||||||
|
let duration_ms = started.elapsed().as_millis() as u64;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
// 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();
|
||||||
|
// 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");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
|
|||||||
100
tests/contrast.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
|
||||||
|
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
||||||
|
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
||||||
|
//
|
||||||
|
// node tests/contrast.js
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
|
||||||
|
const blocks = {};
|
||||||
|
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||||
|
const vars = {};
|
||||||
|
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
||||||
|
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lum = h => {
|
||||||
|
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||||
|
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||||
|
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
|
||||||
|
};
|
||||||
|
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||||||
|
|
||||||
|
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
||||||
|
const RULES = [
|
||||||
|
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
||||||
|
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
||||||
|
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
||||||
|
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
|
||||||
|
['ink', ['accent'], 4.5], // a primary button's label
|
||||||
|
['ink', ['accent2'], 4.5], // the unread count on its badge
|
||||||
|
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
|
||||||
|
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
|
||||||
|
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
|
||||||
|
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
||||||
|
];
|
||||||
|
|
||||||
|
const base = blocks[':root'];
|
||||||
|
const themes = {};
|
||||||
|
for (const sel of Object.keys(blocks)) {
|
||||||
|
const t = sel.match(/data-theme="(\w+)"/);
|
||||||
|
if (!t) continue;
|
||||||
|
const light = /data-mode="light"/.test(sel);
|
||||||
|
// A theme's dark half is :root under its own block; its light half adds the light block.
|
||||||
|
const name = `${t[1]}${light ? ' light' : ''}`;
|
||||||
|
themes[name] = light
|
||||||
|
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
|
||||||
|
: { ...base, ...blocks[sel] };
|
||||||
|
}
|
||||||
|
themes['modern'] = base;
|
||||||
|
|
||||||
|
let bad = 0;
|
||||||
|
for (const [name, p] of Object.entries(themes)) {
|
||||||
|
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||||
|
const r = ratio(p[fg], p[g]);
|
||||||
|
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
||||||
|
}
|
||||||
|
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
||||||
|
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
|
||||||
|
}
|
||||||
|
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
|
||||||
|
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
|
||||||
|
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
|
||||||
|
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
|
||||||
|
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
|
||||||
|
const rgba = (sel, n) => {
|
||||||
|
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
|
||||||
|
return [[+m[1], +m[2], +m[3]], +m[4]];
|
||||||
|
};
|
||||||
|
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||||
|
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
|
||||||
|
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
|
||||||
|
const p = themes[name];
|
||||||
|
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
|
||||||
|
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
|
||||||
|
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
|
||||||
|
const worst = {};
|
||||||
|
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
||||||
|
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
||||||
|
let g = rgb(p.bg);
|
||||||
|
for (const [w, [c, a]] of tint) {
|
||||||
|
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
||||||
|
g = over(c, a * Math.max(0, 1 - d), g);
|
||||||
|
}
|
||||||
|
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
|
||||||
|
// and a panel's top-left corner under its sheen at full strength.
|
||||||
|
const panel = over(rgb(p.panel), .7, g);
|
||||||
|
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
|
||||||
|
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
||||||
|
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
|
||||||
|
if (!(k in worst) || r < worst[k]) worst[k] = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
|
||||||
|
for (const [k, r] of Object.entries(worst))
|
||||||
|
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
|
||||||
|
}
|
||||||
|
if (bad) process.exit(1);
|
||||||
|
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);
|
||||||
BIN
tests/data/access-forger.der
Normal file
BIN
tests/data/access-test.der
Normal file
12
tests/data/access-test.jwks.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "k1",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"e": "AQAB",
|
||||||
|
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
131
tests/dom-stub.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
|
||||||
|
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
|
||||||
|
// to find what it reaches for, and no more.
|
||||||
|
//
|
||||||
|
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
||||||
|
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
||||||
|
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
||||||
|
// nothing about whether it worked.
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
function makeContext(html, script, { media = false } = {}) {
|
||||||
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||||
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||||
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||||
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||||
|
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||||
|
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||||
|
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||||
|
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||||
|
|
||||||
|
const body = el('body');
|
||||||
|
const audio = media ? makeMediaElement() : null;
|
||||||
|
if (media) {
|
||||||
|
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
||||||
|
// one has to be a real set rather than something that always says no.
|
||||||
|
const classes = new Set();
|
||||||
|
body.classList = {
|
||||||
|
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||||
|
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
querySelector(sel) {
|
||||||
|
if (sel === '#audio' && audio) return audio;
|
||||||
|
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
||||||
|
return el(sel);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => el('created'),
|
||||||
|
addEventListener(){}, body,
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
document, console,
|
||||||
|
window: { isSecureContext: false, addEventListener(){} },
|
||||||
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||||
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||||
|
fetch: (url) => Promise.resolve({
|
||||||
|
ok: true, status: 200, text: () => Promise.resolve(''),
|
||||||
|
json: () => Promise.resolve(
|
||||||
|
String(url).includes('/api/settings')
|
||||||
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||||
|
: String(url).includes('/api/users')
|
||||||
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||||
|
: /\/api\/(popular|directory)/.test(String(url))
|
||||||
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||||
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||||
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||||
|
}),
|
||||||
|
EventSource: function () { this.close = () => {}; },
|
||||||
|
MediaMetadata: function () {},
|
||||||
|
Blob: function () {},
|
||||||
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
|
confirm: () => false, prompt: () => null, alert(){},
|
||||||
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||||
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||||
|
};
|
||||||
|
if (media) {
|
||||||
|
ctx.HTMLMediaElement = MediaElement;
|
||||||
|
ctx.Event = Event;
|
||||||
|
ctx.isFinite = isFinite;
|
||||||
|
ctx.CSS = { escape: s => String(s) };
|
||||||
|
}
|
||||||
|
ctx.globalThis = ctx;
|
||||||
|
ctx.window.location = { href: '', hash: '' };
|
||||||
|
ctx.location = ctx.window.location;
|
||||||
|
|
||||||
|
return { ctx, missing, audio, body, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
||||||
|
|
||||||
|
function Event(type) { this.type = type; }
|
||||||
|
|
||||||
|
function MediaElement() {
|
||||||
|
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
|
||||||
|
this._ready = 0; this._vol = 1; this._rate = 1;
|
||||||
|
this._listeners = {};
|
||||||
|
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
||||||
|
// Every call that reached the real element, so a test can say the host took over rather than
|
||||||
|
// the element quietly playing as well.
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
|
||||||
|
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
|
||||||
|
MediaElement.prototype.load = function(){ this.calls.push('load'); };
|
||||||
|
MediaElement.prototype.addEventListener = function(name, fn, opts){
|
||||||
|
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
|
||||||
|
};
|
||||||
|
MediaElement.prototype.dispatchEvent = function(ev){
|
||||||
|
for (const l of (this._listeners[ev.type] || []).slice()) {
|
||||||
|
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
|
||||||
|
l.fn(ev);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
|
||||||
|
MediaElement.prototype.setAttribute = function(){};
|
||||||
|
MediaElement.prototype.getAttribute = function(){ return null; };
|
||||||
|
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
|
||||||
|
configurable: true,
|
||||||
|
get(){ return this[field]; },
|
||||||
|
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
|
||||||
|
});
|
||||||
|
accessor('src', '_src', true);
|
||||||
|
accessor('currentTime', '_t', true);
|
||||||
|
accessor('volume', '_vol');
|
||||||
|
accessor('playbackRate', '_rate');
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
|
||||||
|
|
||||||
|
function makeMediaElement(){ return new MediaElement(); }
|
||||||
|
|
||||||
|
module.exports = { makeContext, vm };
|
||||||
117
tests/native-bridge.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
||||||
|
// hand it to the host, while everything in player.ts carries on talking to the element.
|
||||||
|
//
|
||||||
|
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
||||||
|
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
||||||
|
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
||||||
|
// road, with nothing to look at.
|
||||||
|
//
|
||||||
|
// node tests/native-bridge.js
|
||||||
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
|
const { buildPage } = require('../web/build.mjs');
|
||||||
|
|
||||||
|
const { html, js: script } = buildPage('index.html');
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
||||||
|
|
||||||
|
/* ---- a browser: nothing installs ---- */
|
||||||
|
{
|
||||||
|
const { ctx } = makeContext(html, script, { media: true });
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
|
||||||
|
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
||||||
|
const audio = ctx.document.querySelector('#audio');
|
||||||
|
audio.src = '/media/1';
|
||||||
|
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- inside the shell ---- */
|
||||||
|
const posted = [];
|
||||||
|
const { ctx, audio, body } = makeContext(html, script, { media: true });
|
||||||
|
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
||||||
|
|
||||||
|
const last = t => [...posted].reverse().find(m => m.t === t);
|
||||||
|
const since = () => posted.splice(0, posted.length);
|
||||||
|
|
||||||
|
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
||||||
|
ok(last('ready'), 'the host was never told the bridge is in');
|
||||||
|
since();
|
||||||
|
|
||||||
|
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
||||||
|
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
||||||
|
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
||||||
|
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
||||||
|
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
||||||
|
|
||||||
|
body.classList.toggle('has-video', false);
|
||||||
|
audio.calls.length = 0;
|
||||||
|
audio.src = '/media/42';
|
||||||
|
|
||||||
|
const load = last('load');
|
||||||
|
ok(load, 'setting the src told the host nothing');
|
||||||
|
if (load) {
|
||||||
|
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
||||||
|
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
||||||
|
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
||||||
|
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
||||||
|
}
|
||||||
|
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
||||||
|
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
||||||
|
|
||||||
|
// player.ts sets currentTime=0 straight after the src.
|
||||||
|
since();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
|
||||||
|
|
||||||
|
since();
|
||||||
|
audio.play();
|
||||||
|
ok(last('play'), 'play did not reach the host');
|
||||||
|
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
||||||
|
|
||||||
|
// The host answers, and the page must move as it would have on its own.
|
||||||
|
let played = 0, timed = 0;
|
||||||
|
audio.addEventListener('play', () => played++);
|
||||||
|
audio.addEventListener('timeupdate', () => timed++);
|
||||||
|
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
||||||
|
ok(played === 1, 'the page never saw the host start playing');
|
||||||
|
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
||||||
|
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
||||||
|
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
||||||
|
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
||||||
|
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
||||||
|
|
||||||
|
// The 15-second key: a read and a write through the shim.
|
||||||
|
since();
|
||||||
|
audio.currentTime -= 15;
|
||||||
|
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
|
||||||
|
|
||||||
|
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
||||||
|
since();
|
||||||
|
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
||||||
|
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
||||||
|
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
|
||||||
|
|
||||||
|
// Closing the player has to stop the host, not just blank the element.
|
||||||
|
since();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
ok(last('stop'), 'closing the player left the host playing');
|
||||||
|
|
||||||
|
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
||||||
|
// buys nothing.
|
||||||
|
since();
|
||||||
|
audio.calls.length = 0;
|
||||||
|
body.classList.toggle('has-video', true);
|
||||||
|
audio.src = '/media/99';
|
||||||
|
ok(!last('load'), 'a video was handed to the host');
|
||||||
|
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
||||||
|
|
||||||
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||||
|
console.log('OK: native-bridge: the host takes playback and the page follows it');
|
||||||
|
process.exit(0);
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
// and every server-side test passed too, because the server was fine.
|
// and every server-side test passed too, because the server was fine.
|
||||||
//
|
//
|
||||||
// node tests/page-smoke.js
|
// node tests/page-smoke.js
|
||||||
const vm = require('vm');
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
const PAGE = process.argv[2] || 'index.html';
|
const PAGE = process.argv[2] || 'index.html';
|
||||||
|
|
||||||
const { buildPage } = require('../web/build.mjs');
|
const { buildPage } = require('../web/build.mjs');
|
||||||
@@ -19,57 +19,7 @@ if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && P
|
|||||||
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
||||||
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
||||||
}
|
}
|
||||||
// Ids in the page, and in the markup the script builds for its dialogs.
|
const { ctx, missing } = makeContext(html, script);
|
||||||
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
|
||||||
|
|
||||||
const missing = [];
|
|
||||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
|
||||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
|
||||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
|
||||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
|
||||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
|
||||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
|
||||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
|
||||||
|
|
||||||
const document = {
|
|
||||||
querySelector(sel) {
|
|
||||||
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
|
||||||
return el(sel);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
createElement: () => el('created'),
|
|
||||||
addEventListener(){}, body: el('body'),
|
|
||||||
documentElement: { dataset: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
document, console,
|
|
||||||
window: { isSecureContext: false, addEventListener(){} },
|
|
||||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
|
||||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
|
||||||
fetch: (url) => Promise.resolve({
|
|
||||||
ok: true, status: 200, text: () => Promise.resolve(''),
|
|
||||||
json: () => Promise.resolve(
|
|
||||||
String(url).includes('/api/settings')
|
|
||||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
|
||||||
: String(url).includes('/api/users')
|
|
||||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
|
||||||
: /\/api\/(popular|directory)/.test(String(url))
|
|
||||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
||||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
||||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
||||||
}),
|
|
||||||
EventSource: function () { this.close = () => {}; },
|
|
||||||
MediaMetadata: function () {},
|
|
||||||
Blob: function () {},
|
|
||||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
||||||
confirm: () => false, prompt: () => null, alert(){},
|
|
||||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
|
||||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
||||||
};
|
|
||||||
ctx.globalThis = ctx;
|
|
||||||
ctx.window.location = { href: '', hash: '' };
|
|
||||||
ctx.location = ctx.window.location;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
|
|||||||
// Auto follows the system, live, with no reload.
|
// Auto follows the system, live, with no reload.
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
await expect.poll(root).toEqual(['modern', 'light']);
|
await expect.poll(root).toEqual(['modern', 'light']);
|
||||||
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
|
await expect.poll(bg).toBe('rgb(238, 245, 251)'); // Modern's light --bg
|
||||||
await page.emulateMedia({ colorScheme: 'dark' });
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
await expect.poll(root).toEqual(['modern', 'dark']);
|
await expect.poll(root).toEqual(['modern', 'dark']);
|
||||||
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
|
await expect.poll(bg).toBe('rgb(10, 23, 38)'); // Modern's dark --bg
|
||||||
|
|
||||||
// Dracula, then its light half, Alucard.
|
// Dracula, then its light half, Alucard.
|
||||||
await page.locator('#stheme').selectOption('dracula');
|
await page.locator('#stheme').selectOption('dracula');
|
||||||
@@ -63,13 +63,9 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
|
|||||||
await page.locator('#stheme').selectOption('paper');
|
await page.locator('#stheme').selectOption('paper');
|
||||||
await expect(page.locator('#smode')).toBeHidden();
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
||||||
// The save that says Classic: the ones before it may still be answering.
|
|
||||||
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
|
|
||||||
&& r.request().postDataJSON().theme === 'classic');
|
|
||||||
await page.locator('#stheme').selectOption('classic');
|
await page.locator('#stheme').selectOption('classic');
|
||||||
await expect(page.locator('#smode')).toBeHidden();
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
await saved; // kept on the account, not the browser
|
await page.reload(); // kept in this browser's cookie
|
||||||
await page.reload();
|
|
||||||
await expect.poll(root).toEqual(['classic', 'light']);
|
await expect.poll(root).toEqual(['classic', 'light']);
|
||||||
// The 2004 Mac app set its type in Lucida Grande.
|
// The 2004 Mac app set its type in Lucida Grande.
|
||||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||||
@@ -83,46 +79,40 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
|
|||||||
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
|
test('each browser keeps its own theme, in a cookie, not on the account', async ({ page, browser }) => {
|
||||||
|
// Glass on a phone, Dracula on a desktop, signed in as the same person (issue #69).
|
||||||
|
let patched = false;
|
||||||
|
page.on('request', r => { if (r.url().endsWith('/api/me') && r.method() === 'PATCH') patched = true; });
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
|
||||||
await page.locator('#stheme').selectOption('flatremix');
|
await page.locator('#stheme').selectOption('flatremix');
|
||||||
expect((await saved).status()).toBe(204);
|
|
||||||
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
|
||||||
await page.locator('#smode').selectOption('light');
|
await page.locator('#smode').selectOption('light');
|
||||||
await saved2;
|
const cookie = (await page.context().cookies()).find(c => c.name === 'ipx_theme');
|
||||||
|
expect(cookie?.value).toBe('flatremix.light');
|
||||||
|
expect(patched, 'nothing sent to the account').toBe(false);
|
||||||
|
|
||||||
// Another browser: nothing in its localStorage, and the page still arrives in the theme,
|
// This browser: the server reads the cookie and draws the page in it from the first frame.
|
||||||
// written onto <html> by the server rather than set once the script has run.
|
const res = await page.reload();
|
||||||
|
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
|
||||||
|
|
||||||
|
// Another browser, the same account: not this one's theme.
|
||||||
const other = await browser.newContext();
|
const other = await browser.newContext();
|
||||||
const p2 = await other.newPage();
|
const p2 = await other.newPage();
|
||||||
const res = await p2.goto(`/?token=${TOKEN}`);
|
const res2 = await p2.goto(`/?token=${TOKEN}`);
|
||||||
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
|
expect(await res2.text()).not.toContain('data-theme=flatremix');
|
||||||
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
|
||||||
.toEqual(['flatremix', 'light']);
|
|
||||||
await other.close();
|
await other.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
|
test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
|
||||||
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
|
|
||||||
const who = `theme-${Date.now()}@example.com`;
|
const who = `theme-${Date.now()}@example.com`;
|
||||||
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
||||||
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||||
.toEqual(['modern', 'light']);
|
.toEqual(['modern', 'light']);
|
||||||
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
|
expect((await ctx.cookies()).find(c => c.name === 'ipx_theme')?.value).toBe('modern.light');
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
|
|
||||||
// Anywhere else now, it comes from the account.
|
|
||||||
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
|
||||||
const p2 = await fresh.newPage();
|
|
||||||
const res = await p2.goto('/');
|
|
||||||
expect(await res.text()).toContain('data-theme=modern data-choice=light');
|
|
||||||
await fresh.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the admin page saves the global schedule', async ({ page }) => {
|
test('the admin page saves the global schedule', async ({ page }) => {
|
||||||
@@ -470,6 +460,11 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
|
|||||||
test.describe('on a phone', () => {
|
test.describe('on a phone', () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
test('the top bar leaves the logo out, for the search box', async ({ page }) => {
|
||||||
|
await expect(page.locator('#burger')).toBeVisible();
|
||||||
|
await expect(page.locator('#applogo')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
||||||
// The burger used to live in the player bar, which is hidden until something plays --
|
// The burger used to live in the player bar, which is hidden until something plays --
|
||||||
// leaving no way to reach the feeds at all.
|
// leaving no way to reach the feeds at all.
|
||||||
@@ -558,10 +553,12 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
const ctx = await browser.newContext();
|
const ctx = await browser.newContext();
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
// The sign-in page shows the icon, so it has to load before anyone has signed in.
|
// The sign-in page shows the logo, so it has to load before anyone has signed in.
|
||||||
const icon = await page.request.get('/icon.png');
|
for (const path of ['/logo.svg', '/logo-dark.svg']) {
|
||||||
expect(icon.status()).toBe(200);
|
const logo = await page.request.get(path);
|
||||||
expect(icon.headers()['content-type']).toBe('image/png');
|
expect(logo.status(), path).toBe(200);
|
||||||
|
expect(logo.headers()['content-type'], path).toBe('image/svg+xml');
|
||||||
|
}
|
||||||
await page.locator('#name').fill('sam');
|
await page.locator('#name').fill('sam');
|
||||||
await page.locator('#pw').fill('sampassword');
|
await page.locator('#pw').fill('sampassword');
|
||||||
await page.locator('button[type=submit]').click();
|
await page.locator('button[type=submit]').click();
|
||||||
@@ -576,7 +573,7 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||||
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await expect(page.locator('#admin')).toHaveCount(0);
|
await expect(page.locator('#admin')).toHaveCount(0);
|
||||||
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
||||||
// Asking for it anyway goes back to the app, and its script is refused.
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
@@ -930,6 +927,66 @@ test('adding a feed scans it straight away', async ({ page }) => {
|
|||||||
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('adding a page adds the feed it links, and a page with no feed is refused', async ({ page }) => {
|
||||||
|
const feeds = await page.locator('.feed').count();
|
||||||
|
await page.locator('#addFeed').click();
|
||||||
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/nofeed.html');
|
||||||
|
await page.locator('#nsave').click();
|
||||||
|
await expect(page.locator('.toast')).toContainText('links no feed');
|
||||||
|
await expect(page.locator('#modal.on')).toBeVisible(); // left open to correct it
|
||||||
|
await expect(page.locator('.feed')).toHaveCount(feeds);
|
||||||
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/site.html');
|
||||||
|
await page.locator('#nsave').click();
|
||||||
|
await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => {
|
||||||
|
const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first();
|
||||||
|
await expect(unread).toBeVisible({ timeout: 20_000 });
|
||||||
|
const id = await unread.getAttribute('data-id');
|
||||||
|
const badge = page.locator(`.feed[data-id="${id}"] .badge`);
|
||||||
|
const before = Number(await badge.textContent());
|
||||||
|
await unread.click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible();
|
||||||
|
const lists = [];
|
||||||
|
page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); });
|
||||||
|
await page.locator('.ep').first().click(); // opening an item reads it
|
||||||
|
await expect(badge).toHaveText(String(before - 1));
|
||||||
|
expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('artwork comes from iPX, kept, and only as an image', async ({ page }) => {
|
||||||
|
// Every image a feed names is drawn from iPX's address.
|
||||||
|
expect(await page.evaluate(() => artHTML('https://example.com/a.jpg', 'A'))).toContain('src="/api/art?u=https%3A%2F%2Fexample.com%2Fa.jpg"');
|
||||||
|
// Multi Show's items name art.jpg.
|
||||||
|
await expect(page.locator('.feed', { hasText: 'Multi Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const src = '/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/art.jpg');
|
||||||
|
for (const _ of [1, 2]) { // fetched, then kept
|
||||||
|
const r = await page.request.get(src);
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
expect(r.headers()['content-type']).toMatch(/^image\//);
|
||||||
|
expect(r.headers()['content-security-policy']).toContain('sandbox');
|
||||||
|
}
|
||||||
|
// An address no feed names is not fetched.
|
||||||
|
expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item without a title is named from its text, then its file, then its show and date', async ({ page }) => {
|
||||||
|
const names = await page.evaluate(() => [
|
||||||
|
entryName({ title: 'A Title' }),
|
||||||
|
entryName({ title: ' ', description: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
|
||||||
|
entryName({ description: '<p>' + 'word '.repeat(40) + '</p>' }).text.length,
|
||||||
|
entryName({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] }),
|
||||||
|
entryName({ feed_id: 'nobody', published: 0 }),
|
||||||
|
]);
|
||||||
|
expect(names[0]).toEqual({ text: 'A Title', derived: false });
|
||||||
|
expect(names[1]).toEqual({ text: 'I like the way AI is evolving.', derived: true });
|
||||||
|
expect(names[2]).toBeLessThanOrEqual(121); // cut at a word, with …
|
||||||
|
expect(names[3]).toEqual({ text: 'KARTAS691', derived: true });
|
||||||
|
expect(names[4]).toEqual({ text: 'nobody', derived: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
||||||
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
||||||
page.on('dialog', d => d.accept());
|
page.on('dialog', d => d.accept());
|
||||||
@@ -1178,32 +1235,62 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
|
|||||||
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the logo is the dark one in dark mode and the light one in light mode', async ({ page }) => {
|
||||||
|
const shown = () => page.locator('#applogo img:visible').getAttribute('src');
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo-dark\.svg\?v=/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'light'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
||||||
|
// Paper has only a light palette, so the light logo whatever the mode asked.
|
||||||
|
await page.evaluate(() => setTheme('paper', 'dark'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/api/status gives a dashboard the counts, with the shared token as a cookie', async ({ page }) => {
|
||||||
|
// As Homepage's customapi widget asks: no session, only the token in a Cookie header.
|
||||||
|
const r = await page.request.get('/api/status', { headers: { cookie: `ipx_token=${TOKEN}` } });
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
const s = await r.json();
|
||||||
|
for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number');
|
||||||
|
expect(s.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
const none = await page.request.get('/api/status', { headers: { cookie: '' } });
|
||||||
|
expect(none.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
||||||
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
|
await page.evaluate(() => setTheme('modern', 'light'));
|
||||||
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon-dark\.png\?v=[0-9a-f]{12}$/);
|
||||||
// A browser asks for /favicon.ico on its own, signed in or not.
|
// A browser asks for /favicon.ico on its own, signed in or not.
|
||||||
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
|
for (const path of ['/favicon.ico', '/favicon.png', '/favicon-dark.png', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']) {
|
||||||
const r = await page.request.get(path, { headers: { cookie: '' } });
|
const r = await page.request.get(path, { headers: { cookie: '' } });
|
||||||
expect(r.status(), path).toBe(200);
|
expect(r.status(), path).toBe(200);
|
||||||
expect(r.headers()['content-type'], path).toBe('image/png');
|
expect(r.headers()['content-type'], path).toBe('image/png');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
|
test('a failing feed is marked on its artwork and says why', async ({ page }) => {
|
||||||
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
||||||
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
||||||
await page.locator('.feed.group .chev').first().click();
|
await page.locator('.feed.group .chev').first().click();
|
||||||
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
|
// Faked in the page: no fixture feed fails. A feed on its own failing for a day, and one
|
||||||
await page.evaluate(() => {
|
// inside a folder that failed its last check.
|
||||||
|
const solo = await page.evaluate(() => {
|
||||||
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
||||||
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
|
const f = S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id));
|
||||||
|
f.last_error = 'HTTP 404 Not Found';
|
||||||
|
f.failing = { reason: 'The publisher took this feed down, or moved it.' };
|
||||||
renderFeeds();
|
renderFeeds();
|
||||||
|
return f.id;
|
||||||
});
|
});
|
||||||
await expect(page.locator('.ferr')).toHaveCount(2);
|
await expect(page.locator('.fart .ferr svg')).toHaveCount(3); // both feeds and the folder
|
||||||
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
|
|
||||||
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
||||||
const xs = await page.$$eval('.chev, .ferr', els =>
|
const row = page.locator(`.feed[data-id="${solo}"]`);
|
||||||
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
|
await expect(row).toHaveClass(/failing/);
|
||||||
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
|
await expect(row.locator('small')).toHaveText('The publisher took this feed down, or moved it.');
|
||||||
|
await expect(page.locator('.feed.group.err small').first()).toHaveText('1 feed not updating');
|
||||||
await page.reload(); // put the real list back
|
await page.reload(); // put the real list back
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1211,14 +1298,15 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
||||||
async function drag(page, from, to) {
|
async function drag(page, from, to, lift = true) {
|
||||||
const cdp = await page.context().newCDPSession(page);
|
const cdp = await page.context().newCDPSession(page);
|
||||||
const steps = 8;
|
const steps = 8;
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
||||||
for (let i = 1; i <= steps; i++)
|
for (let i = 1; i <= steps; i++)
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
||||||
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
return cdp;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
||||||
@@ -1231,6 +1319,17 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
const shown = page.locator('#detail .dt');
|
const shown = page.locator('#detail .dt');
|
||||||
await expect(shown).toHaveText(titles[0]);
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
|
||||||
|
// Held partway: the next item is beside the reader, not the list under it (#52), and
|
||||||
|
// let go short of a swipe, it goes again.
|
||||||
|
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
|
||||||
|
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
|
||||||
|
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
|
||||||
|
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await expect(page.locator('#dpeek')).toHaveCount(0);
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
await expect(shown).toHaveText(titles[1]);
|
await expect(shown).toHaveText(titles[1]);
|
||||||
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
||||||
@@ -1243,6 +1342,34 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
// Earlier tests read these; both have to be unread to be on the Unread tab.
|
||||||
|
await page.evaluate(() => Promise.all(S.entries.map(e => setRead(e, false))));
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const titles = await page.locator('.ep .t').allTextContents();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const shown = page.locator('#detail .dt');
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
// The first is read now, and used to be gone from the list already, so this went back to
|
||||||
|
// the list instead.
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 });
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 });
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
// Out of the reader, the ones read on the way leave the Unread tab as before.
|
||||||
|
await page.locator('#dback').click();
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
|
await expect(page.locator('.ep .t', { hasText: titles[0] })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
||||||
await page.locator('#burger').click();
|
await page.locator('#burger').click();
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
@@ -1252,6 +1379,15 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
||||||
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
||||||
|
// and a spinner says the check started, for a couple of seconds.
|
||||||
|
await expect(page.locator('#pullspin')).toBeVisible();
|
||||||
|
// A second pull while it is up checks nothing more.
|
||||||
|
let again = 0;
|
||||||
|
page.on('request', r => { if (r.url().endsWith('/api/fetch')) again++; });
|
||||||
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
|
await expect(page.locator('#pullspin')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#pullspin')).toHaveCount(0, { timeout: 5_000 });
|
||||||
|
expect(again).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1300,19 +1436,91 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
|||||||
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a pinned item goes to the top of its list, and back when unpinned', async ({ page }) => {
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||||
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
await expect(row).toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||||
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge')).toBeVisible();
|
||||||
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
|
await page.locator('#scanAll').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
|
||||||
|
// piper, made in an earlier test, subscribes to Test Show alone.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
|
||||||
|
// Listen as the page does, then ask.
|
||||||
|
await piper.evaluate(() => {
|
||||||
|
window.started = []; window.done = false;
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.onmessage = m => { const ev = JSON.parse(m.data);
|
||||||
|
if (ev.ev === 'feed_start') window.started.push(ev.feed);
|
||||||
|
if (ev.ev === 'scan_done') window.done = true; };
|
||||||
|
});
|
||||||
|
await piper.waitForTimeout(500);
|
||||||
|
await piper.locator('#scanAll').click();
|
||||||
|
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
|
||||||
|
const started = await piper.evaluate(() => window.started);
|
||||||
|
expect(started.length, 'something was checked').toBeGreaterThan(0);
|
||||||
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('words in Settings hide the items that mention them', async ({ page }) => {
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
|
||||||
const guids = () => page.locator('.ep').evaluateAll(rows => rows.map(r => r.dataset.guid));
|
|
||||||
const before = await guids();
|
await page.locator('#prefs').click();
|
||||||
const last = before[before.length - 1];
|
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
|
||||||
const row = page.locator(`.ep[data-guid="${last}"]`);
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
await row.locator('[data-a="flag"]').click();
|
await page.locator('#sblock').press('Tab');
|
||||||
await expect.poll(async () => (await guids())[0]).toBe(last);
|
expect((await saved).status()).toBe(204);
|
||||||
// It is the server's order, so it holds on a reload.
|
await page.locator('#modal .cardx').click();
|
||||||
await page.reload();
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
|
||||||
await expect.poll(async () => (await guids())[0]).toBe(last);
|
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
|
||||||
await page.locator(`.ep[data-guid="${last}"] [data-a="flag"]`).click();
|
|
||||||
await expect.poll(guids).toEqual(before);
|
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
|
||||||
|
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await page.locator('#content .acts [data-a="share"]').click();
|
||||||
|
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
||||||
|
await page.locator('.encbox [data-a="share"]').first().click();
|
||||||
|
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
|
||||||
|
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
|
||||||
|
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh button turns while what it checks is being checked', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
||||||
|
const btn = '.fhead [data-a=scan] .i';
|
||||||
|
await expect(page.locator(btn)).toBeVisible();
|
||||||
|
expect(await spin(btn)).toBe('none');
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
expect(await spin(btn)).toBe('ipxspin');
|
||||||
|
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||||
|
// Another feed being checked turns the toolbar's, not this page's.
|
||||||
|
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); });
|
||||||
|
expect(await spin(btn)).toBe('none');
|
||||||
|
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||||
|
await page.evaluate(() => setScanning('multi-show', false));
|
||||||
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
});
|
});
|
||||||
|
|||||||
4
tests/ui/fixtures/linked.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>Linked Site</title><link>http://127.0.0.1:8792/site.html</link>
|
||||||
|
<item><title>Linked Post</title><guid>linked-1</guid></item>
|
||||||
|
</channel></rss>
|
||||||
2
tests/ui/fixtures/nofeed.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><title>No Feed Here</title></head><body>A site that links no feed.</body></html>
|
||||||
@@ -12,7 +12,10 @@ http.createServer((req, res) => {
|
|||||||
fs.readFile(file, (err, body) => {
|
fs.readFile(file, (err, body) => {
|
||||||
if (err) { res.writeHead(404).end('no'); return; }
|
if (err) { res.writeHead(404).end('no'); return; }
|
||||||
const type = file.endsWith('.mp3') ? 'audio/mpeg'
|
const type = file.endsWith('.mp3') ? 'audio/mpeg'
|
||||||
: file.endsWith('.opml') ? 'text/x-opml' : 'application/xml';
|
: file.endsWith('.opml') ? 'text/x-opml'
|
||||||
|
// Artwork as an image, or /api/art refuses it as not one.
|
||||||
|
: file.endsWith('.jpg') ? 'image/jpeg'
|
||||||
|
: file.endsWith('.html') ? 'text/html' : 'application/xml';
|
||||||
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
});
|
});
|
||||||
|
|||||||
4
tests/ui/fixtures/site.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><title>A Site</title>
|
||||||
|
<link rel="alternate" type="application/rss+xml" href="/linked.xml">
|
||||||
|
</head><body>A site with a feed.</body></html>
|
||||||
@@ -4,16 +4,16 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>iPodderX admin</title>
|
<title>iPX admin</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="adminpage">
|
<body class="adminpage">
|
||||||
<!-- The server sends this page, and its script, to admins only. -->
|
<!-- The server sends this page, and its script, to admins only. -->
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
|
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||||
<img class="logo" src="/icon.png" alt="" width="26">
|
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26">
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<nav class="tabs" id="atabs">
|
<nav class="tabs" id="atabs">
|
||||||
|
|||||||
578
web/app.css
@@ -2,44 +2,44 @@
|
|||||||
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
|
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
|
||||||
keeps Lucida Grande, the 2004 app's face. */
|
keeps Lucida Grande, the 2004 app's face. */
|
||||||
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
|
/* Palette taken from the logo (web/logo.svg, logo-dark.svg): its navy ground, the tuning scale's
|
||||||
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
|
blue and the orange needle. The dark half follows logo-dark.svg. */
|
||||||
:root {
|
:root {
|
||||||
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
--bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
|
||||||
--panel:#151c27;
|
--panel:#0f1f31;
|
||||||
--panel2:#1c2431;
|
--panel2:#15283d;
|
||||||
--raise:#25303f;
|
--raise:#1d3450;
|
||||||
--line:#2c3849;
|
--line:#264060;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f3f7fb;
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#a3b6ca;
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#8a9fb5;
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0a1726; /* text on an accent fill */
|
||||||
--good:#6fbf8b;
|
--good:#6fbf8b;
|
||||||
--warn:#f49e2c; /* the amber doubles as the pending colour */
|
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
|
||||||
--bad:#e2705f;
|
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(4,10,20,.55);
|
||||||
}
|
}
|
||||||
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
|
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
|
||||||
script sets data-mode to light or dark, working Auto out from the system, so each theme has
|
script sets data-mode to light or dark, working Auto out from the system, so each theme has
|
||||||
one block per variant here and none needs repeating under a media query. */
|
one block per variant here and none needs repeating under a media query. */
|
||||||
:root[data-theme="modern"][data-mode="light"] {
|
:root[data-theme="modern"][data-mode="light"] {
|
||||||
--bg:#f2f4f7;
|
--bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
--panel:#ffffff;
|
||||||
--panel2:#e9edf3;
|
--panel2:#e4eff9;
|
||||||
--raise:#dde3ec;
|
--raise:#d5e6f5;
|
||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#c6d9ea;
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#10233a;
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#46596e;
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#56697e;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2f6aa0; /* #2F6AA0 the logo's scale */
|
||||||
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
--accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3263a; /* pushed towards crimson, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(47,106,160,.14);
|
||||||
}
|
}
|
||||||
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
|
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
|
||||||
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
|
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
--dim:#c4c4c8;
|
--dim:#c4c4c8;
|
||||||
--faint:#a0a0a7;
|
--faint:#a0a0a7;
|
||||||
--accent:#81d0ff;
|
--accent:#81d0ff;
|
||||||
--accent2:#3584e4;
|
--accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
|
||||||
--ink:#1d1d20;
|
--ink:#1d1d20;
|
||||||
--good:#78e9ab;
|
--good:#78e9ab;
|
||||||
--warn:#ffc252;
|
--warn:#ffc252;
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
--dim:#57575d;
|
--dim:#57575d;
|
||||||
--faint:#66666c;
|
--faint:#66666c;
|
||||||
--accent:#0461be;
|
--accent:#0461be;
|
||||||
--accent2:#3584e4;
|
--accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#00753a;
|
--good:#00753a;
|
||||||
--warn:#905400;
|
--warn:#905400;
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
--dim:#5c616c; /* fg */
|
--dim:#5c616c; /* fg */
|
||||||
--faint:#686d78;
|
--faint:#686d78;
|
||||||
--accent:#0060f0; /* link */
|
--accent:#0060f0; /* link */
|
||||||
--accent2:#fd7d00;
|
--accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#23794f;
|
--good:#23794f;
|
||||||
--warn:#a85400;
|
--warn:#a85400;
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
--dim:#3d3a33;
|
--dim:#3d3a33;
|
||||||
--faint:#5a564c;
|
--faint:#5a564c;
|
||||||
--accent:#1a5fae;
|
--accent:#1a5fae;
|
||||||
--accent2:#b58900;
|
--accent2:#957100; /* taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#216609;
|
--good:#216609;
|
||||||
--warn:#795a00;
|
--warn:#795a00;
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
--panel:#3b4252; /* nord1 */
|
--panel:#3b4252; /* nord1 */
|
||||||
--panel2:#434c5e; /* nord2 */
|
--panel2:#434c5e; /* nord2 */
|
||||||
--raise:#4c566a; /* nord3 */
|
--raise:#4c566a; /* nord3 */
|
||||||
--line:#434c5e;
|
--line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
|
||||||
--fg:#eceff4; /* nord6 */
|
--fg:#eceff4; /* nord6 */
|
||||||
--dim:#d8dee9; /* nord4 */
|
--dim:#d8dee9; /* nord4 */
|
||||||
--faint:#b4bccb;
|
--faint:#b4bccb;
|
||||||
@@ -232,13 +232,158 @@
|
|||||||
--dim:#3b4252; /* nord1 */
|
--dim:#3b4252; /* nord1 */
|
||||||
--faint:#4c566a; /* nord3 */
|
--faint:#4c566a; /* nord3 */
|
||||||
--accent:#3a5e8a;
|
--accent:#3a5e8a;
|
||||||
--accent2:#b0674e;
|
--accent2:#ab644c;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#446632;
|
--good:#446632;
|
||||||
--warn:#7e590e;
|
--warn:#7e590e;
|
||||||
--bad:#9c3f49;
|
--bad:#9c3f49;
|
||||||
--shadow:0 8px 28px rgba(46,52,64,.15);
|
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||||
}
|
}
|
||||||
|
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
|
||||||
|
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
|
||||||
|
and peach for the rest. */
|
||||||
|
:root[data-theme="catppuccin"] {
|
||||||
|
--bg:#1e1e2e; /* base */
|
||||||
|
--panel:#181825; /* mantle */
|
||||||
|
--panel2:#313244; /* surface0 */
|
||||||
|
--raise:#45475a; /* surface1 */
|
||||||
|
--line:#45475a;
|
||||||
|
--fg:#cdd6f4; /* text */
|
||||||
|
--dim:#bac2de; /* subtext1 */
|
||||||
|
--faint:#a6adc8; /* subtext0 */
|
||||||
|
--accent:#cba6f7; /* mauve */
|
||||||
|
--accent2:#fab387; /* peach */
|
||||||
|
--ink:#11111b; /* crust */
|
||||||
|
--good:#a6e3a1;
|
||||||
|
--warn:#f9e2af;
|
||||||
|
--bad:#f38ba8;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||||
|
--bg:#eff1f5; /* base */
|
||||||
|
--panel:#e6e9ef; /* mantle */
|
||||||
|
--panel2:#dce0e8; /* crust */
|
||||||
|
--raise:#ccd0da; /* surface0 */
|
||||||
|
--line:#bcc0cc; /* surface1 */
|
||||||
|
--fg:#4c4f69; /* text */
|
||||||
|
--dim:#5c5f77; /* subtext1 */
|
||||||
|
--faint:#5f6276; /* subtext0 */
|
||||||
|
--accent:#8638ec; /* mauve */
|
||||||
|
--accent2:#c74e09; /* peach */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#3a9127;
|
||||||
|
--warn:#925d13;
|
||||||
|
--bad:#d00f39;
|
||||||
|
--shadow:0 8px 28px rgba(76,79,105,.15);
|
||||||
|
}
|
||||||
|
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
|
||||||
|
half as gruvbox itself does. Blue for the accent, orange for what is new. */
|
||||||
|
:root[data-theme="gruvbox"] {
|
||||||
|
--bg:#282828; /* bg0 */
|
||||||
|
--panel:#1d2021; /* bg0_h */
|
||||||
|
--panel2:#32302f; /* bg0_s */
|
||||||
|
--raise:#3c3836; /* bg1 */
|
||||||
|
--line:#504945; /* bg2 */
|
||||||
|
--fg:#ebdbb2; /* fg */
|
||||||
|
--dim:#d5c4a1; /* fg2 */
|
||||||
|
--faint:#a89984; /* fg4 */
|
||||||
|
--accent:#83a598; /* blue */
|
||||||
|
--accent2:#fe8019; /* orange */
|
||||||
|
--ink:#1d2021;
|
||||||
|
--good:#b8bb26;
|
||||||
|
--warn:#fabd2f;
|
||||||
|
--bad:#fb533f;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||||
|
--bg:#fbf1c7; /* bg0 */
|
||||||
|
--panel:#f2e5bc; /* bg0_s */
|
||||||
|
--panel2:#ebdbb2; /* bg1 */
|
||||||
|
--raise:#d5c4a1; /* bg2 */
|
||||||
|
--line:#d5c4a1;
|
||||||
|
--fg:#3c3836; /* fg */
|
||||||
|
--dim:#504945; /* fg2 */
|
||||||
|
--faint:#665c54; /* fg3 */
|
||||||
|
--accent:#076678; /* faded blue */
|
||||||
|
--accent2:#af3a03; /* faded orange */
|
||||||
|
--ink:#fbf1c7;
|
||||||
|
--good:#79740e;
|
||||||
|
--warn:#8d5c10;
|
||||||
|
--bad:#9d0006;
|
||||||
|
--shadow:0 8px 28px rgba(60,56,54,.15);
|
||||||
|
}
|
||||||
|
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
|
||||||
|
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
|
||||||
|
:root[data-theme="solarized"] {
|
||||||
|
--bg:#002b36; /* base03 */
|
||||||
|
--panel:#073642; /* base02 */
|
||||||
|
--panel2:#0b3f4c;
|
||||||
|
--raise:#124a58;
|
||||||
|
--line:#1d5563;
|
||||||
|
--fg:#eee8d5; /* base2 */
|
||||||
|
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
|
||||||
|
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
|
||||||
|
--accent:#4b9fda; /* blue */
|
||||||
|
--accent2:#b58900; /* yellow */
|
||||||
|
--ink:#002b36;
|
||||||
|
--good:#859900;
|
||||||
|
--warn:#bb9315;
|
||||||
|
--bad:#e87674;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="solarized"][data-mode="light"] {
|
||||||
|
--bg:#fdf6e3; /* base3 */
|
||||||
|
--panel:#eee8d5; /* base2 */
|
||||||
|
--panel2:#e6dfca;
|
||||||
|
--raise:#ddd5bd;
|
||||||
|
--line:#d3cab0;
|
||||||
|
--fg:#073642; /* base02 */
|
||||||
|
--dim:#52666d; /* base01 */
|
||||||
|
--faint:#54666d; /* base00 */
|
||||||
|
--accent:#1e6da5; /* blue */
|
||||||
|
--accent2:#cb4b16; /* orange */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#768700;
|
||||||
|
--warn:#846400;
|
||||||
|
--bad:#c52d2a;
|
||||||
|
--shadow:0 8px 28px rgba(0,43,54,.14);
|
||||||
|
}
|
||||||
|
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
|
||||||
|
that show. For reading, not for looks. */
|
||||||
|
:root[data-theme="contrast"] {
|
||||||
|
--bg:#000000;
|
||||||
|
--panel:#000000;
|
||||||
|
--panel2:#121212;
|
||||||
|
--raise:#333333;
|
||||||
|
--line:#9a9a9a;
|
||||||
|
--fg:#ffffff;
|
||||||
|
--dim:#ffffff;
|
||||||
|
--faint:#d6d6d6;
|
||||||
|
--accent:#8cc8ff;
|
||||||
|
--accent2:#ffd400;
|
||||||
|
--ink:#000000;
|
||||||
|
--good:#7ee787;
|
||||||
|
--warn:#ffd400;
|
||||||
|
--bad:#ff9a8f;
|
||||||
|
--shadow:0 0 0 1px #9a9a9a;
|
||||||
|
}
|
||||||
|
:root[data-theme="contrast"][data-mode="light"] {
|
||||||
|
--bg:#ffffff;
|
||||||
|
--panel:#ffffff;
|
||||||
|
--panel2:#f0f0f0;
|
||||||
|
--raise:#d6d6d6;
|
||||||
|
--line:#555555;
|
||||||
|
--fg:#000000;
|
||||||
|
--dim:#000000;
|
||||||
|
--faint:#303030;
|
||||||
|
--accent:#0033a0;
|
||||||
|
--accent2:#7a3d00;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#0a5c1c;
|
||||||
|
--warn:#6b4500;
|
||||||
|
--bad:#a30000;
|
||||||
|
--shadow:0 0 0 1px #555555;
|
||||||
|
}
|
||||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||||
:root[data-theme="classic"] {
|
:root[data-theme="classic"] {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
@@ -250,14 +395,65 @@
|
|||||||
--fg:#000000;
|
--fg:#000000;
|
||||||
--dim:#444444;
|
--dim:#444444;
|
||||||
--faint:#666666;
|
--faint:#666666;
|
||||||
--accent:#3875d7; /* Aqua selection blue */
|
--accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
|
||||||
--accent2:#2a5db0;
|
--accent2:#2a5db0;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#237a23;
|
--good:#237a23;
|
||||||
--warn:#a15f00;
|
--warn:#9b5b00;
|
||||||
--bad:#c42b1c;
|
--bad:#c42b1c;
|
||||||
--shadow:0 4px 16px rgba(0,0,0,.28);
|
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||||
}
|
}
|
||||||
|
/* Glass, after Apple's Liquid Glass: Apple's system colours, on panels that let a soft coloured
|
||||||
|
wash show through. The hex values are what a panel reads as once blended, so the contrast check
|
||||||
|
still means something; the translucency and the wash are in the chrome at the end of the sheet. */
|
||||||
|
:root[data-theme="glass"] {
|
||||||
|
--bg:#0b0d14;
|
||||||
|
--panel:#161a24;
|
||||||
|
--panel2:#1f2430;
|
||||||
|
--raise:#2a3040;
|
||||||
|
--line:#343b4c;
|
||||||
|
--fg:#f5f5f7; /* Apple's label */
|
||||||
|
--dim:#aeaeb2; /* systemGray2 */
|
||||||
|
--faint:#98989f; /* systemGray, a shade up to clear AA over the wash */
|
||||||
|
--accent:#409cff; /* systemBlue's accessible dark variant; #0a84ff falls short as link text */
|
||||||
|
--accent2:#ff9f0a; /* systemOrange */
|
||||||
|
--ink:#000000;
|
||||||
|
--good:#30d158;
|
||||||
|
--warn:#ff9f0a;
|
||||||
|
--bad:#ff6961;
|
||||||
|
--shadow:0 10px 36px rgba(0,0,0,.45);
|
||||||
|
--glass-edge:rgba(255,255,255,.10);
|
||||||
|
--glass-hi:rgba(255,255,255,.26);
|
||||||
|
--glass-lo:rgba(255,255,255,.10);
|
||||||
|
--glass-sheen:rgba(255,255,255,.07);
|
||||||
|
--wash1:rgba(64,120,255,.30);
|
||||||
|
--wash2:rgba(175,82,222,.24);
|
||||||
|
--wash3:rgba(48,176,199,.20);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"][data-mode="light"] {
|
||||||
|
--bg:#eef1f7;
|
||||||
|
--panel:#ffffff;
|
||||||
|
--panel2:#f2f4f8;
|
||||||
|
--raise:#e4e8f0;
|
||||||
|
--line:#d1d5de;
|
||||||
|
--fg:#1d1d1f;
|
||||||
|
--dim:#515154;
|
||||||
|
--faint:#5a5a5f; /* systemGray, taken down to clear AA over the wash */
|
||||||
|
--accent:#0055aa; /* a shade under Apple's #0066cc, which falls short over the wash */
|
||||||
|
--accent2:#b25000; /* systemOrange taken down until white on it clears AA */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#248a3d;
|
||||||
|
--warn:#824200;
|
||||||
|
--bad:#b8000f;
|
||||||
|
--shadow:0 10px 36px rgba(30,50,90,.16);
|
||||||
|
--glass-edge:rgba(255,255,255,.65);
|
||||||
|
--glass-hi:rgba(255,255,255,.9);
|
||||||
|
--glass-lo:rgba(255,255,255,.45);
|
||||||
|
--glass-sheen:rgba(255,255,255,.5);
|
||||||
|
--wash1:rgba(64,120,255,.22);
|
||||||
|
--wash2:rgba(175,82,222,.16);
|
||||||
|
--wash3:rgba(48,176,199,.18);
|
||||||
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||||
[hidden]{display:none!important}
|
[hidden]{display:none!important}
|
||||||
@@ -297,19 +493,34 @@ a{color:var(--accent)}
|
|||||||
background:var(--panel);border-right:1px solid var(--line);
|
background:var(--panel);border-right:1px solid var(--line);
|
||||||
display:flex;flex-direction:column;min-height:0;
|
display:flex;flex-direction:column;min-height:0;
|
||||||
}
|
}
|
||||||
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
|
#applogo{display:flex;flex:none}
|
||||||
.brand .logo{
|
#applogo img{width:28px;height:28px;border-radius:7px}
|
||||||
width:30px;height:30px;flex:none;object-fit:contain;
|
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
|
||||||
}
|
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
|
||||||
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
|
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
|
||||||
.iconbtn{
|
.iconbtn{
|
||||||
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
||||||
color:var(--dim);flex:none;
|
color:var(--dim);flex:none;
|
||||||
}
|
}
|
||||||
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||||
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
||||||
|
/* The display's own intrusions -- the Dynamic Island and status bar along the top (#54), the
|
||||||
|
home indicator along the bottom, the notch at one side in landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
|
||||||
|
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
|
||||||
|
padding. A browser with its own chrome reports nought and nothing moves. */
|
||||||
|
:root{
|
||||||
|
--safe-t:env(safe-area-inset-top,0px);
|
||||||
|
--safe-b:env(safe-area-inset-bottom,0px);
|
||||||
|
--safe-l:env(safe-area-inset-left,0px);
|
||||||
|
--safe-r:env(safe-area-inset-right,0px);
|
||||||
|
}
|
||||||
#topbar{
|
#topbar{
|
||||||
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
|
display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden;
|
||||||
|
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
|
||||||
|
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
|
||||||
|
the whole declaration away, leaving the bar with no padding at all. */
|
||||||
|
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
|
||||||
|
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
||||||
background:var(--panel);border-bottom:1px solid var(--line);
|
background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
}
|
}
|
||||||
#topbar .grow{flex:1}
|
#topbar .grow{flex:1}
|
||||||
@@ -337,7 +548,7 @@ a{color:var(--accent)}
|
|||||||
.who button:hover{color:var(--fg)}
|
.who button:hover{color:var(--fg)}
|
||||||
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||||
.sidefoot i{font-style:normal;opacity:.75}
|
.sidefoot i{font-style:normal;opacity:.75}
|
||||||
.searchwrap{padding:0 12px 8px}
|
.searchwrap{padding:12px 12px 8px}
|
||||||
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
||||||
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
|
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
|
||||||
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
||||||
@@ -355,7 +566,14 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
||||||
.feed.child{margin-left:11px}
|
.feed.child{margin-left:11px}
|
||||||
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
|
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
|
||||||
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
|
/* Pinned: a disc on the artwork's corner, as a failing feed's mark is, in the accent and the ink
|
||||||
|
the theme pairs with it. A feed both pinned and failing keeps the error below, the pin above. */
|
||||||
|
.fpin{
|
||||||
|
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
|
||||||
|
display:grid;place-items:center;background:var(--accent);color:var(--ink);border:2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.fpin .i{width:9px;height:9px}
|
||||||
|
.feed.err .fpin{bottom:auto;top:-5px}
|
||||||
.feed.lastpin{margin-bottom:9px}
|
.feed.lastpin{margin-bottom:9px}
|
||||||
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
||||||
.feed.child .art{width:28px;height:28px;font-size:11px}
|
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||||
@@ -368,11 +586,27 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.chev:hover{color:var(--fg)}
|
.chev:hover{color:var(--fg)}
|
||||||
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
||||||
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
|
||||||
a child's included, which is why it moves left by the child's indent. */
|
.feed.scanning .badge{display:none}
|
||||||
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
.feed.scanning::after{
|
||||||
.ferr .i{width:12px;height:12px}
|
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
|
||||||
.feed.child .ferr{left:-27px}
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ipxspin{to{transform:rotate(360deg)}}
|
||||||
|
body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,body.scan-any #scanAll .i{
|
||||||
|
animation:ipxspin 1s linear infinite}
|
||||||
|
/* A feed's error mark: a solid disc on the artwork's corner, ringed in the page ground so it
|
||||||
|
stands off any cover. The glyph is cut out of it in --bg, which is the dark against the light
|
||||||
|
--bad of a dark theme and the light against the deep one of a light theme. */
|
||||||
|
.fart{position:relative;flex:none;display:grid}
|
||||||
|
.ferr{
|
||||||
|
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
|
||||||
|
display:grid;place-items:center;background:var(--bad);color:var(--bg);border:2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.ferr .i{width:9px;height:9px}
|
||||||
|
.feed.err .txt small{color:var(--bad)}
|
||||||
|
/* Failing for a day or more: the cover goes grey, a show gone off the air. */
|
||||||
|
.feed.failing .fart>.art{filter:grayscale(1);opacity:.45}
|
||||||
.chev .i{width:12px;height:12px;transition:transform .12s}
|
.chev .i{width:12px;height:12px;transition:transform .12s}
|
||||||
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
|
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
|
||||||
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
|
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
|
||||||
@@ -421,9 +655,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
||||||
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||||||
.tile:hover .txt b{color:var(--accent)}
|
.tile:hover .txt b{color:var(--accent)}
|
||||||
|
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
|
||||||
|
themes, where on the row's own ground they clear it. */
|
||||||
.tag{
|
.tag{
|
||||||
font-size:11px;font-weight:600;
|
font-size:11px;font-weight:600;
|
||||||
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
|
||||||
}
|
}
|
||||||
.art{
|
.art{
|
||||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||||
@@ -444,6 +680,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
||||||
}
|
}
|
||||||
.badge.zero{background:var(--raise);color:var(--faint)}
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
|
/* A selected row is --raise too, and the zero count's pill vanished into it. */
|
||||||
|
.sel .badge.zero{background:var(--bg)}
|
||||||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||||
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
@@ -457,7 +695,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
|
|
||||||
/* Three panes, as the original had: feeds beside, items above, the item below. */
|
/* Three panes, as the original had: feeds beside, items above, the item below. */
|
||||||
#split{
|
#split{
|
||||||
display:grid;flex:1;min-height:0;
|
display:grid;flex:1;min-height:0;overflow:hidden; /* the reader sliding on a swipe */
|
||||||
grid-template-columns:minmax(0,1fr) 270px;
|
grid-template-columns:minmax(0,1fr) 270px;
|
||||||
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
||||||
grid-template-areas:"list files" "grab grab" "detail detail";
|
grid-template-areas:"list files" "grab grab" "detail detail";
|
||||||
@@ -467,6 +705,12 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
reloads the page, from starting on top of it. */
|
reloads the page, from starting on top of it. */
|
||||||
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
||||||
font-size:12px;color:var(--faint)}
|
font-size:12px;color:var(--faint)}
|
||||||
|
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
|
||||||
|
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
|
||||||
|
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
|
||||||
|
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||||
|
#pullspin::before{content:"";width:12px;height:12px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
|
||||||
/* The selected item's files, beside the list, as the original's Files pane was. */
|
/* The selected item's files, beside the list, as the original's Files pane was. */
|
||||||
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
|
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
|
||||||
/* Nothing selected, or an item with no files: the list takes the width. */
|
/* Nothing selected, or an item with no files: the list takes the width. */
|
||||||
@@ -499,9 +743,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
|
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
|
||||||
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
|
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
|
||||||
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
||||||
.acts .btn.primary i{opacity:.9}
|
|
||||||
.acts .btn:hover{background:var(--raise)}
|
.acts .btn:hover{background:var(--raise)}
|
||||||
.acts .btn.primary:hover{background:var(--accent)}
|
|
||||||
.btn{
|
.btn{
|
||||||
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||||
padding:6px 12px;font-size:13px;
|
padding:6px 12px;font-size:13px;
|
||||||
@@ -546,7 +788,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
|
|||||||
body.adminpage{display:block;overflow:auto}
|
body.adminpage{display:block;overflow:auto}
|
||||||
body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
||||||
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
||||||
body.adminpage #topbar .logo{border-radius:5px}
|
body.adminpage #topbar .logo{border-radius:6px}
|
||||||
body.adminpage #topbar > a.btn{text-decoration:none}
|
body.adminpage #topbar > a.btn{text-decoration:none}
|
||||||
body.adminpage #admin{max-width:780px;margin:0 auto}
|
body.adminpage #admin{max-width:780px;margin:0 auto}
|
||||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||||
@@ -602,6 +844,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
|||||||
@keyframes eq{from{transform:scaleY(.35)}}
|
@keyframes eq{from{transform:scaleY(.35)}}
|
||||||
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
|
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
|
||||||
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
/* An item with no title shows its opening words: set as text, not as a heading. */
|
||||||
|
.ep .t.notitle{font-weight:400}
|
||||||
.ep.read .t{color:var(--dim);font-weight:500}
|
.ep.read .t{color:var(--dim);font-weight:500}
|
||||||
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
|
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
|
||||||
.ep .line:empty{display:none}
|
.ep .line:empty{display:none}
|
||||||
@@ -627,7 +871,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
|||||||
#player{
|
#player{
|
||||||
border-top:1px solid var(--line);background:var(--panel);
|
border-top:1px solid var(--line);background:var(--panel);
|
||||||
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
||||||
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
padding-top:9px;padding-bottom:calc(9px + var(--safe-b));
|
||||||
|
padding-left:calc(16px + var(--safe-l));padding-right:calc(16px + var(--safe-r));
|
||||||
|
box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||||
}
|
}
|
||||||
#player.on{display:grid}
|
#player.on{display:grid}
|
||||||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||||
@@ -690,6 +936,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
||||||
.logbar .grow{flex:1;min-width:120px}
|
.logbar .grow{flex:1;min-width:120px}
|
||||||
.card h3{margin:0 0 14px;font-size:17px}
|
.card h3{margin:0 0 14px;font-size:17px}
|
||||||
|
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
|
||||||
|
at the foot of a long card. */
|
||||||
|
.cardx{float:right;margin:-5px -6px 0 8px}
|
||||||
.field{display:grid;gap:4px;margin-bottom:12px}
|
.field{display:grid;gap:4px;margin-bottom:12px}
|
||||||
.field label{font-size:12px;color:var(--dim)}
|
.field label{font-size:12px;color:var(--dim)}
|
||||||
.field .hint{font-size:11.5px;color:var(--faint)}
|
.field .hint{font-size:11.5px;color:var(--faint)}
|
||||||
@@ -706,14 +955,20 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||||
}
|
}
|
||||||
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
|
||||||
|
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
|
||||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||||
#burger,#dback{display:none}
|
#burger,#dback{display:none}
|
||||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||||
#status{
|
#status{
|
||||||
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
padding-top:3px;padding-bottom:calc(3px + var(--safe-b));
|
||||||
|
padding-left:calc(14px + var(--safe-l));padding-right:calc(14px + var(--safe-r));
|
||||||
|
min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
||||||
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||||
}
|
}
|
||||||
|
/* Only the last bar along the bottom owes the indicator anything, and with a player open that
|
||||||
|
is the player, not this. Without :has() the worst of it is a gap above the player bar. */
|
||||||
|
body:has(#player.on) #status{padding-bottom:3px}
|
||||||
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
||||||
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
||||||
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
||||||
@@ -734,14 +989,23 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
|
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
#shell{grid-template-columns:1fr}
|
#shell{grid-template-columns:1fr}
|
||||||
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
||||||
#sidebar.open{transform:none}
|
#sidebar.open{transform:none}
|
||||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||||
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
|
#player{
|
||||||
|
position:relative;z-index:39;gap:8px;
|
||||||
|
padding-top:7px;padding-bottom:calc(7px + var(--safe-b));
|
||||||
|
padding-left:calc(10px + var(--safe-l));padding-right:calc(10px + var(--safe-r));
|
||||||
|
}
|
||||||
|
|
||||||
/* The feed list is reachable whether or not anything is playing. */
|
/* The feed list is reachable whether or not anything is playing. */
|
||||||
#burger{display:grid}
|
#burger{display:grid}
|
||||||
#topbar{gap:6px;padding:6px 8px}
|
/* No logo on a phone: the bar has no room to spare for it, and no hover to show its version. */
|
||||||
|
#applogo{display:none}
|
||||||
|
#topbar{
|
||||||
|
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
|
||||||
|
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
|
||||||
|
}
|
||||||
#topbar .grow,.tgroup.item{display:none}
|
#topbar .grow,.tgroup.item{display:none}
|
||||||
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
||||||
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
||||||
@@ -754,9 +1018,21 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
|
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
|
||||||
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
||||||
#grab{display:none}
|
#grab{display:none}
|
||||||
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
|
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t))}
|
||||||
body.reading #detail{display:block}
|
body.reading #detail{display:block}
|
||||||
#dback{display:inline-block;margin-bottom:10px}
|
#dback{display:inline-block;margin-bottom:10px}
|
||||||
|
/* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the
|
||||||
|
page's own background between them, so the list under the reader never shows (#52). */
|
||||||
|
#dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none}
|
||||||
|
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
|
||||||
|
#dpeek.next>div{margin-left:16px}
|
||||||
|
#dpeek .encbox{margin:0 0 10px}
|
||||||
|
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px}
|
||||||
|
#dpeek.end p{max-width:4.5em;margin:0}
|
||||||
|
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
|
||||||
|
reader, shadowed along its edge, is drawn off it. */
|
||||||
|
#dpeek.dim{width:100%;z-index:37;background:#000}
|
||||||
|
#detail.popping{box-shadow:-12px 0 32px rgba(0,0,0,.28)}
|
||||||
|
|
||||||
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
|
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
|
||||||
#content>.fhead{padding-top:12px}
|
#content>.fhead{padding-top:12px}
|
||||||
@@ -788,6 +1064,90 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
#logbox .tg{display:none}
|
#logbox .tg{display:none}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- A phone, drawn the way iOS draws its own apps (#53) ---------- */
|
||||||
|
/* Filled shapes rather than outlines, corners that grow rounder as the shape grows and nest inside
|
||||||
|
each other, 44px to tap and 17px to read. The outlines stay where they are what makes a control
|
||||||
|
visible at all: the high-contrast theme, and anyone who has asked the system for more contrast.
|
||||||
|
--edge is the one switch for that. */
|
||||||
|
@media (max-width:820px){
|
||||||
|
:root{--edge:transparent}
|
||||||
|
:root[data-theme="contrast"]{--edge:var(--line)}
|
||||||
|
/* Glass rules its panels off with an edge of its own; the light along them (--glass-rim) stays. */
|
||||||
|
:root[data-theme="glass"],:root[data-theme="glass"][data-mode="light"]{--glass-edge:transparent}
|
||||||
|
}
|
||||||
|
@media (max-width:820px) and (prefers-contrast:more){
|
||||||
|
:root{--edge:var(--line)}
|
||||||
|
}
|
||||||
|
@media (max-width:820px){
|
||||||
|
/* The bars run into the page instead of being ruled off from it. */
|
||||||
|
#topbar{background:var(--bg);border-bottom-color:var(--edge);gap:8px}
|
||||||
|
#status{background:var(--bg);border-top-color:var(--edge)}
|
||||||
|
#player{border-top-color:var(--edge)}
|
||||||
|
.sidefoot{border-top-color:var(--edge)}
|
||||||
|
|
||||||
|
/* Buttons: filled and round. Hover is reset because Safari keeps it on whatever was last
|
||||||
|
tapped, which left an accent outline on it. */
|
||||||
|
.btn,.sidetools button,.sidefoot button,.tgroup,.toast,kbd,#logbox{border-color:var(--edge)}
|
||||||
|
.btn:hover,.sidetools button:hover,.sidefoot button:hover{border-color:var(--edge)}
|
||||||
|
.btn,.sidetools button,.sidefoot button{border-radius:999px;min-height:40px;padding-left:14px;padding-right:14px}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||||
|
.btn.ico,#dback{min-width:40px;height:40px;padding:0 10px;display:inline-grid;place-items:center}
|
||||||
|
.btn.tiny{min-height:0;padding:2px 9px}
|
||||||
|
.iconbtn{width:40px;height:40px;border-radius:999px;background:var(--panel2)}
|
||||||
|
.tgroup{border-radius:999px}
|
||||||
|
.tgroup button+button,.tgroup button+a{border-left-color:var(--edge)}
|
||||||
|
.tgroup button,.tgroup a{min-width:36px;height:40px;padding:0 8px}
|
||||||
|
|
||||||
|
/* Text boxes filled, and at 16px or more: Safari zooms the page in on one any smaller. */
|
||||||
|
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
||||||
|
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
|
||||||
|
}
|
||||||
|
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 14px}
|
||||||
|
input:focus,select:focus{border-color:var(--accent)}
|
||||||
|
|
||||||
|
/* The feed's name as the page's large title, the way Podcasts heads a show. */
|
||||||
|
.fhead h2,.fhead.slim h2{font-size:26px;font-weight:700;letter-spacing:-.01em;line-height:1.2}
|
||||||
|
.fhead .sub{font-size:15px}
|
||||||
|
.acts{gap:8px}
|
||||||
|
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
|
||||||
|
|
||||||
|
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
|
||||||
|
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
|
||||||
|
.tabs button,.tabs a{flex:1;text-align:center;padding:7px 4px;border-radius:8px;font-size:14px}
|
||||||
|
.tabs button.on,.tabs a.on{box-shadow:0 1px 3px rgba(0,0,0,.22)}
|
||||||
|
|
||||||
|
/* Rows: taller, larger type, and a hairline between them that starts where the text does. */
|
||||||
|
.ep{padding:10px 8px;border-radius:10px;min-height:44px}
|
||||||
|
.ep.sel{border-color:var(--edge)}
|
||||||
|
.ep:hover:not(.sel){background-color:transparent}
|
||||||
|
.ep .t{font-size:17px}
|
||||||
|
.ep .line,.ep .date{font-size:15px}
|
||||||
|
.ep+.ep{background-image:linear-gradient(var(--line),var(--line));background-size:calc(100% - 64px) 1px;background-position:64px 0;background-repeat:no-repeat}
|
||||||
|
.ep.sel,.ep.sel+.ep{background-image:none}
|
||||||
|
.feed{padding:9px 10px;min-height:44px;border-radius:10px}
|
||||||
|
.places{border-bottom-color:var(--edge);padding-bottom:10px;margin-bottom:10px}
|
||||||
|
.feed .txt b{font-size:17px}
|
||||||
|
.feed .txt small{font-size:13px}
|
||||||
|
|
||||||
|
/* The reader. */
|
||||||
|
.dt{font-size:26px;font-weight:700;line-height:1.2;letter-spacing:-.01em;margin-bottom:8px}
|
||||||
|
.dmeta{font-size:15px;gap:10px}
|
||||||
|
.encbox{border-color:var(--edge);border-radius:14px;padding:8px 8px 8px 14px}
|
||||||
|
.dbody{font-size:17px;line-height:1.55}
|
||||||
|
|
||||||
|
/* Dialogs are sheets, up from the bottom. */
|
||||||
|
#modal{place-items:end stretch;padding:0}
|
||||||
|
.card,.card.wide{
|
||||||
|
width:100%;max-height:calc(100dvh - var(--safe-t) - 12px);border-color:var(--edge);border-radius:20px 20px 0 0;
|
||||||
|
padding:20px 16px calc(20px + var(--safe-b));animation:sheet .3s cubic-bezier(.2,.8,.2,1);
|
||||||
|
}
|
||||||
|
.card h3{font-size:22px}
|
||||||
|
.field label,.field .hint{font-size:13px}
|
||||||
|
.check{font-size:17px}
|
||||||
|
.toast{border-radius:14px}
|
||||||
|
}
|
||||||
|
@keyframes sheet{from{transform:translateY(100%)}}
|
||||||
|
|
||||||
/* ---------- Classic: the 2004 Mac app ---------- */
|
/* ---------- Classic: the 2004 Mac app ---------- */
|
||||||
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
|
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
|
||||||
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
|
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
|
||||||
@@ -838,10 +1198,102 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
:root[data-theme="classic"] .ep.sel .st,
|
:root[data-theme="classic"] .ep.sel .st,
|
||||||
:root[data-theme="classic"] .ep.sel .fl,
|
:root[data-theme="classic"] .ep.sel .fl,
|
||||||
:root[data-theme="classic"] .ep.sel .file,
|
:root[data-theme="classic"] .ep.sel .file,
|
||||||
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
|
:root[data-theme="classic"] .ep.sel .kind,
|
||||||
|
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
|
||||||
/* Green and red stay green and red on the blue, just lighter so they read. */
|
/* Green and red stay green and red on the blue, just lighter so they read. */
|
||||||
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
||||||
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
||||||
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
||||||
:root[data-theme="classic"] .childrow{background:#fff}
|
:root[data-theme="classic"] .childrow{background:#fff}
|
||||||
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
||||||
|
|
||||||
|
/* ---------- Glass: after Apple's Liquid Glass ---------- */
|
||||||
|
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
|
||||||
|
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
|
||||||
|
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43).
|
||||||
|
What is kept is the light: the rim catches it at the top-left edges and, fainter, the far ones,
|
||||||
|
as it passes through (#51). */
|
||||||
|
:root[data-theme="glass"]{--glass-rim:inset 1px 1px 0 var(--glass-hi),inset -1px -1px 0 var(--glass-lo)}
|
||||||
|
:root[data-theme="glass"] body{
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 55% at 8% 0%,var(--wash1),transparent),
|
||||||
|
radial-gradient(50% 50% at 92% 18%,var(--wash2),transparent),
|
||||||
|
radial-gradient(60% 60% at 60% 100%,var(--wash3),transparent),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] #sidebar,
|
||||||
|
:root[data-theme="glass"] #topbar,
|
||||||
|
:root[data-theme="glass"] #player,
|
||||||
|
:root[data-theme="glass"] #status,
|
||||||
|
:root[data-theme="glass"] #files,
|
||||||
|
:root[data-theme="glass"] #detail,
|
||||||
|
:root[data-theme="glass"] #dpeek>div,
|
||||||
|
:root[data-theme="glass"] .card,
|
||||||
|
:root[data-theme="glass"] .toast{
|
||||||
|
/* A sheen from the top-left corner, where the light comes from. It is a background layer rather
|
||||||
|
than a pseudo-element because the detail pane, the file list and the dialogs scroll, and an
|
||||||
|
absolutely placed layer inside a scroller scrolls away with the content. */
|
||||||
|
background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),color-mix(in srgb,var(--panel) 70%,transparent);
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(180%);
|
||||||
|
backdrop-filter:blur(24px) saturate(180%);
|
||||||
|
border-color:var(--glass-edge);
|
||||||
|
box-shadow:var(--glass-rim);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] .toolbar,
|
||||||
|
:root[data-theme="glass"] .ephead{
|
||||||
|
/* Sticky, so the list scrolls under them: the one place the frosting has something to blur. */
|
||||||
|
background:color-mix(in srgb,var(--bg) 65%,transparent);
|
||||||
|
-webkit-backdrop-filter:blur(20px) saturate(180%);
|
||||||
|
backdrop-filter:blur(20px) saturate(180%);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] .card,
|
||||||
|
:root[data-theme="glass"] .toast{box-shadow:var(--glass-rim),var(--shadow)}
|
||||||
|
:root[data-theme="glass"] .toast{border-radius:14px}
|
||||||
|
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
||||||
|
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
||||||
|
@media (max-width:820px){
|
||||||
|
:root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
|
||||||
|
}
|
||||||
|
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
|
||||||
|
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
|
||||||
|
:root[data-theme="glass"] .tgroup,
|
||||||
|
:root[data-theme="glass"] .tabs{
|
||||||
|
background:color-mix(in srgb,var(--panel2) 60%,transparent);
|
||||||
|
border-color:var(--glass-edge);box-shadow:var(--glass-rim);
|
||||||
|
}
|
||||||
|
/* Rounder than the other themes; a phone has shapes of its own (#53), which these would outrank. */
|
||||||
|
@media (min-width:821px){
|
||||||
|
:root[data-theme="glass"] .card{border-radius:20px}
|
||||||
|
:root[data-theme="glass"] .tgroup,
|
||||||
|
:root[data-theme="glass"] .tabs{border-radius:12px}
|
||||||
|
:root[data-theme="glass"] .btn,
|
||||||
|
:root[data-theme="glass"] .sidetools button,
|
||||||
|
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
||||||
|
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
||||||
|
light only catches the top edge there, and the bars at the top and bottom have none. */
|
||||||
|
@media (max-width:820px){
|
||||||
|
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
|
||||||
|
:root[data-theme="glass"] #topbar,
|
||||||
|
:root[data-theme="glass"] #status{box-shadow:none}
|
||||||
|
}
|
||||||
|
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
||||||
|
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
||||||
|
:root[data-theme="glass"] body{background:var(--bg)}
|
||||||
|
:root[data-theme="glass"] #sidebar,
|
||||||
|
:root[data-theme="glass"] #topbar,
|
||||||
|
:root[data-theme="glass"] #player,
|
||||||
|
:root[data-theme="glass"] #status,
|
||||||
|
:root[data-theme="glass"] #files,
|
||||||
|
:root[data-theme="glass"] #detail,
|
||||||
|
:root[data-theme="glass"] #dpeek>div,
|
||||||
|
:root[data-theme="glass"] .card,
|
||||||
|
:root[data-theme="glass"] .toast,
|
||||||
|
:root[data-theme="glass"] .tgroup,
|
||||||
|
:root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
|
||||||
|
:root[data-theme="glass"] .toolbar,
|
||||||
|
:root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||||
|
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
'login.html': { script: 'login.js', src: ['login'] },
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
};
|
};
|
||||||
@@ -38,7 +38,14 @@ export function buildStyle({ minify = true } = {}) {
|
|||||||
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
||||||
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
||||||
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
||||||
|
// The minifier drops the space between a calc() and the value after it in a shorthand --
|
||||||
|
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
|
||||||
|
// declaration away, so the element silently loses its padding. It reports no error and the
|
||||||
|
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
|
||||||
|
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
|
||||||
|
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||||
@@ -65,6 +72,11 @@ export function buildPage(name, { minify = true } = {}) {
|
|||||||
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
||||||
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
||||||
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
||||||
|
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
||||||
|
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||||
|
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||||
|
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g,
|
||||||
|
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
||||||
if (!minify) return { html: out, js, script };
|
if (!minify) return { html: out, js, script };
|
||||||
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
||||||
|
|||||||
BIN
web/favicon-dark.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
web/favicon.png
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -2,10 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>iPodderX</title>
|
<title>iPX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
||||||
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
||||||
A phone hides the item group, which it has no table for. -->
|
A phone hides the item group, which it has no table for. -->
|
||||||
|
<span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"></span>
|
||||||
<div class="tgroup">
|
<div class="tgroup">
|
||||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
||||||
@@ -33,9 +34,6 @@
|
|||||||
</header>
|
</header>
|
||||||
<div id="shell">
|
<div id="shell">
|
||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<div class="brand">
|
|
||||||
<img class="logo" src="/icon.png" alt="iPodderX" title="The original iPodderX icon, 2004"><h1>iPodderX</h1>
|
|
||||||
</div>
|
|
||||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
||||||
<div id="feedlist"></div>
|
<div id="feedlist"></div>
|
||||||
<div class="sidefoot">
|
<div class="sidefoot">
|
||||||
|
|||||||
@@ -1,44 +1,52 @@
|
|||||||
<title>Sign in — iPodderX</title>
|
<!doctype html>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>Sign in — iPX</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png" media="(prefers-color-scheme:light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-dark.png" media="(prefers-color-scheme:dark)">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<style>
|
<style>
|
||||||
/* Inter, from ipx itself; see the same rule in index.html. */
|
/* Inter, from ipx itself; see the same rule in index.html. */
|
||||||
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
:root {
|
:root {
|
||||||
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
--bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
|
||||||
--panel:#151c27;
|
--panel:#0f1f31;
|
||||||
--panel2:#1c2431;
|
--panel2:#15283d;
|
||||||
--raise:#25303f;
|
--raise:#1d3450;
|
||||||
--line:#2c3849;
|
--line:#264060;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f3f7fb;
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#a3b6ca;
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#8a9fb5;
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0a1726; /* text on an accent fill */
|
||||||
--good:#6fbf8b;
|
--good:#6fbf8b;
|
||||||
--warn:#f49e2c; /* the amber doubles as the pending colour */
|
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
|
||||||
--bad:#e2705f;
|
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(4,10,20,.55);
|
||||||
--r:10px;
|
--r:10px;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] {
|
/* Modern, as app.css has it. Signed out, there is no account to take a theme from, so this follows
|
||||||
--bg:#f2f4f7;
|
the system. */
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
@media (prefers-color-scheme:light){:root {
|
||||||
--panel2:#e9edf3;
|
--bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
|
||||||
--raise:#dde3ec;
|
--panel:#ffffff;
|
||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--panel2:#e4eff9;
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--raise:#d5e6f5;
|
||||||
--dim:#606060; /* #606060 */
|
--line:#c6d9ea;
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--fg:#10233a;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--dim:#46596e;
|
||||||
--accent2:#b06f10;
|
--faint:#56697e;
|
||||||
|
--accent:#2f6aa0; /* #2F6AA0 the logo's scale */
|
||||||
|
--accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3263a; /* pushed towards crimson, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(47,106,160,.14);
|
||||||
}
|
}}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
@@ -49,9 +57,9 @@ form{
|
|||||||
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
||||||
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
||||||
}
|
}
|
||||||
/* The one place the 2004 icon is shown at the size it was drawn for. */
|
/* The logo is a square app icon, so it gets an app icon's corners. */
|
||||||
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
||||||
.brand img{width:96px;height:auto}
|
.brand img{width:72px;height:72px;border-radius:16px}
|
||||||
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
||||||
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
||||||
input{
|
input{
|
||||||
@@ -68,7 +76,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<form id="f">
|
<form id="f">
|
||||||
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div>
|
<div class="brand"><picture><source media="(prefers-color-scheme:light)" srcset="/logo.svg"><img src="/logo-dark.svg" alt=""></picture><h1>iPX</h1></div>
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
<input id="name" name="name" autocomplete="username" autofocus required>
|
<input id="name" name="name" autocomplete="username" autofocus required>
|
||||||
<label for="pw">Password</label>
|
<label for="pw">Password</label>
|
||||||
|
|||||||
19
web/logo-dark.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- logo.svg's dark appearance: the same layers in the same places, only recoloured, as the
|
||||||
|
guidelines ask, so the icon reads as itself in either. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1d4468"/>
|
||||||
|
<stop offset="1" stop-color="#0c2238"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="scale" fill="#8fc2ea">
|
||||||
|
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||||
|
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||||
|
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||||
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1003 B |
22
web/logo.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- The iPodderX radio's screen, reduced to its tuning scale and needle, laid out as Apple's
|
||||||
|
app icon guidelines ask: square and opaque (the system cuts the corners, and paints
|
||||||
|
transparency black), one background and flat foreground layers with hard edges, no
|
||||||
|
highlights or shadows of its own (the system adds them), nothing thin enough to vanish
|
||||||
|
at 32px. Each <g> is a layer, for splitting out into Icon Composer. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#dff0fb"/>
|
||||||
|
<stop offset="1" stop-color="#9fccef"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="scale" fill="#2f6aa0">
|
||||||
|
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||||
|
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||||
|
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||||
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -34,11 +34,11 @@ async function drawServer(){
|
|||||||
</div>
|
</div>
|
||||||
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
||||||
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
||||||
<div class="field"><label>Max new downloads per scan, per feed</label>
|
<div class="field"><label>Newest episodes to download, per feed</label>
|
||||||
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
||||||
<span class="hint">Applies to any feed that does not set its own — including every feed
|
<span class="hint">Applies to any feed that does not set its own — including every feed
|
||||||
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
|
inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means
|
||||||
catalogue the first time a feed is scanned.</span></div>
|
every episode</b>, the whole back catalogue.</span></div>
|
||||||
<div class="field"><label>Download these media types automatically</label>
|
<div class="field"><label>Download these media types automatically</label>
|
||||||
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
||||||
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
||||||
@@ -49,6 +49,10 @@ async function drawServer(){
|
|||||||
items are never touched.</span></div>
|
items are never touched.</span></div>
|
||||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||||
|
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
|
||||||
|
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
||||||
|
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
|
||||||
|
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
|
||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
@@ -59,7 +63,8 @@ async function drawServer(){
|
|||||||
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
||||||
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
max_total_gb: Number($('#gquota').value) || 0,
|
max_total_gb: Number($('#gquota').value) || 0,
|
||||||
max_age_days: Number($('#gage').value) || 0})});
|
max_age_days: Number($('#gage').value) || 0,
|
||||||
|
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
}catch(e){ toast(e.message, true); }
|
}catch(e){ toast(e.message, true); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function listedFeed(p,cls){
|
|||||||
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||||
// Yours already: the row opens it instead.
|
// Yours already: the row opens it instead.
|
||||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
|
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
|
||||||
$('[data-a="sub"]',el).onclick=async()=>{
|
$('[data-a="sub"]',el).onclick=async()=>{
|
||||||
@@ -140,7 +140,7 @@ async function renderListening(url,box){
|
|||||||
el.className='childrow';
|
el.className='childrow';
|
||||||
el.entry=e;
|
el.entry=e;
|
||||||
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
`<div class="txt"><b>${EQ}<span>${esc(entryName(e).text)}</span></b>`+
|
||||||
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
`<button class="iconbtn" data-a="play"></button>`+
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
@@ -218,7 +218,7 @@ function due(ts){
|
|||||||
async function prefsModal(){
|
async function prefsModal(){
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const admin = !!(S.me&&S.me.admin);
|
const admin = !!(S.me&&S.me.admin);
|
||||||
openModal(`<h3>Settings</h3>
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
<div class="field"><label>Theme</label>
|
<div class="field"><label>Theme</label>
|
||||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||||
@@ -234,17 +234,29 @@ async function prefsModal(){
|
|||||||
</div>
|
</div>
|
||||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
subscribes you to every feed in one.</span></div>
|
subscribes you to every feed in one.</span></div>
|
||||||
|
<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||||
|
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
|
||||||
|
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
||||||
|
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||||
|
settings can add more.</span></div>
|
||||||
<div class="field"><label>Feeds are checked every</label>
|
<div class="field"><label>Feeds are checked every</label>
|
||||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
||||||
<div class="field"><label>Download folder</label>
|
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$('#gopml').onclick=opmlModal;
|
||||||
|
$('#sblock').onchange=async e=>{
|
||||||
|
const blocked=splitWords(e.target.value);
|
||||||
|
try{
|
||||||
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
||||||
|
if(S.me) S.me.blocked=blocked;
|
||||||
|
toast('Saved'); await loadFeeds(true); loadEntries();
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
||||||
|
|
||||||
function settingsModal(f, newUrl?: string){
|
function settingsModal(f, newUrl?: string){
|
||||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||||
@@ -258,10 +270,15 @@ function settingsModal(f, newUrl?: string){
|
|||||||
<div class="field"><label>Keywords</label>
|
<div class="field"><label>Keywords</label>
|
||||||
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||||
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
||||||
<div class="field"><label>Max new downloads per scan</label>
|
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||||
|
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
||||||
|
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
||||||
|
is hidden from you and not downloaded for you, as well as those your Settings hide
|
||||||
|
everywhere.</span></div>`}
|
||||||
|
<div class="field"><label>Newest episodes to download</label>
|
||||||
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
||||||
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
|
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay
|
||||||
the next scan.</span></div>
|
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div>
|
||||||
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
||||||
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
||||||
<div class="field"><label>Feed URL</label>
|
<div class="field"><label>Feed URL</label>
|
||||||
@@ -295,9 +312,10 @@ function settingsModal(f, newUrl?: string){
|
|||||||
const max=$('#smax').value;
|
const max=$('#smax').value;
|
||||||
try{
|
try{
|
||||||
const patch: Record<string, unknown>={
|
const patch: Record<string, unknown>={
|
||||||
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
|
keywords:splitWords($('#skw').value),
|
||||||
max_new_per_check:max===''?null:Number(max),
|
max_new_per_check:max===''?null:Number(max),
|
||||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
||||||
|
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
||||||
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||||
if(S.me&&S.me.admin){
|
if(S.me&&S.me.admin){
|
||||||
patch.url=$('#surl').value.trim();
|
patch.url=$('#surl').value.trim();
|
||||||
@@ -371,7 +389,8 @@ function opmlModal(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
$('#scanAll').onclick=scanAll;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
|||||||
@@ -3,14 +3,9 @@ let sse;
|
|||||||
function connect(){
|
function connect(){
|
||||||
sse=new EventSource('/api/events');
|
sse=new EventSource('/api/events');
|
||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshFeeds=soon(()=>loadFeeds(true));
|
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
let fresh={};
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const tellNew=soon(()=>{
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
|
|
||||||
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
|
|
||||||
fresh={};
|
|
||||||
},900);
|
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
if(ev.ev==='progress'){
|
if(ev.ev==='progress'){
|
||||||
@@ -23,22 +18,35 @@ function connect(){
|
|||||||
}
|
}
|
||||||
else if(ev.ev==='download_done'){
|
else if(ev.ev==='download_done'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
refreshEntries();
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
if(bar) bar.classList.remove('live');
|
||||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||||
}
|
}
|
||||||
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||||
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||||
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
else if(ev.ev==='feed_done'){
|
else if(ev.ev==='feed_done'){
|
||||||
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
|
setScanning(ev.feed,false);
|
||||||
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
if(!mine(ev.feed)) return;
|
||||||
|
if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
}
|
}
|
||||||
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
// red ! marks the feed instead, and its page says why.
|
// red ! marks the feed instead, and its page says why.
|
||||||
else if(ev.ev==='feed_error') refreshFeeds();
|
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
|
||||||
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
|
// The server sends a feed's new row after anything changes it (counts, error, last check),
|
||||||
|
// only to those who subscribe; the page used to reload the whole list after each event.
|
||||||
|
else if(ev.ev==='feed_row') patchFeed(ev.row);
|
||||||
|
// Only a scan that checked something: the scheduler scans every minute, due or not, and
|
||||||
|
// every open page reloaded the whole list each time (#103).
|
||||||
|
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
|
||||||
|
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
||||||
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* ---------------- feed page ---------------- */
|
/* ---------------- feed page ---------------- */
|
||||||
function renderFeed(){
|
function renderFeed(){
|
||||||
|
paintScanning(); // another feed's page may be the one open now
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
box.classList.remove('plain');
|
box.classList.remove('plain');
|
||||||
const v=VIEWS[S.feed];
|
const v=VIEWS[S.feed];
|
||||||
@@ -27,10 +28,11 @@ function renderFeed(){
|
|||||||
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
||||||
</div>
|
</div>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
<button class="btn ico" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +45,7 @@ function renderFeed(){
|
|||||||
subscribe to, newest first · ${unreadAll} unread</div>
|
subscribe to, newest first · ${unreadAll} unread</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
<button class="btn ico" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`) + `
|
</div>`) + `
|
||||||
@@ -117,9 +119,10 @@ function renderGroup(f,kids){
|
|||||||
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||||
</div>
|
</div>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
<button class="btn ico" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +159,7 @@ function renderGroup(f,kids){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function feedAction(a,f){
|
async function feedAction(a,f){
|
||||||
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
if(a==='rm') removeFeed(f);
|
if(a==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
@@ -166,6 +169,7 @@ async function feedAction(a,f){
|
|||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(e.message,true); }
|
||||||
}
|
}
|
||||||
if(a==='settings') settingsModal(f);
|
if(a==='settings') settingsModal(f);
|
||||||
|
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||||
if(a==='dl') downloadLatestModal(f);
|
if(a==='dl') downloadLatestModal(f);
|
||||||
}
|
}
|
||||||
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
/* ---------------- feeds ---------------- */
|
/* ---------------- feeds ---------------- */
|
||||||
|
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
|
||||||
|
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
|
||||||
|
let rowsQueued=0;
|
||||||
|
function patchFeed(row){
|
||||||
|
const i=S.feeds.findIndex(f=>f.id===row.id);
|
||||||
|
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
||||||
|
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFeeds(keepSel?: boolean){
|
async function loadFeeds(keepSel?: boolean){
|
||||||
S.feeds = await api('/api/feeds');
|
S.feeds = await api('/api/feeds');
|
||||||
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
// Only for a hint in a feed's settings, so once, on the page's first load, not on every reload
|
||||||
|
// of the list (#103).
|
||||||
|
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||||
renderFeeds();
|
renderFeeds();
|
||||||
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
||||||
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||||
@@ -25,6 +36,27 @@ const VIEWS={
|
|||||||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||||
|
/// holding one, carry a spinner.
|
||||||
|
const scanning=new Set<string>();
|
||||||
|
function setScanning(id: string, on: boolean){
|
||||||
|
if(on) scanning.add(id); else scanning.delete(id);
|
||||||
|
paintScanning();
|
||||||
|
}
|
||||||
|
function paintScanning(){
|
||||||
|
for(const row of $$('#feedlist .feed')){
|
||||||
|
const id=row.dataset.id;
|
||||||
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
row.classList.toggle('scanning',on);
|
||||||
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
|
}
|
||||||
|
// The refresh buttons turn while what they check is being checked (#78). On <body>, because
|
||||||
|
// a feed's page is redrawn as its items come in and would take a class on the button with it.
|
||||||
|
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
document.body.classList.toggle('scan-this',busy(S.feed));
|
||||||
|
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id)));
|
||||||
|
}
|
||||||
|
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop;
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
@@ -84,19 +116,26 @@ function renderFeeds(){
|
|||||||
// went wrong (failBannerHTML); the list only has to make it findable.
|
// went wrong (failBannerHTML); the list only has to make it findable.
|
||||||
const bad=c=>c.failing?.reason||c.last_error;
|
const bad=c=>c.failing?.reason||c.last_error;
|
||||||
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||||
|
const nbad=mine.filter(bad).length;
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
|
// A feed that has failed for a day goes grey, as if switched off: a change in lightness
|
||||||
|
// shows in every theme, where the red mark alone was easy to miss on a dark one.
|
||||||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
||||||
|
(err?' err':'')+(f.failing?' failing':'')+
|
||||||
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
||||||
el.tabIndex=0; el.dataset.id=f.id;
|
el.tabIndex=0; el.dataset.id=f.id;
|
||||||
const open = !!(kids && (expanded.has(f.id) || q));
|
const open = !!(kids && (expanded.has(f.id) || q));
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
// The error mark hangs in the margin where a folder's triangle does. A folder already has
|
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
|
||||||
// its triangle there, so that turns red instead, and the feed inside shows the mark.
|
// The mark sits on the artwork, the thing the eye scans the list by, and the line under
|
||||||
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
|
// the name says what is wrong in place of the counts.
|
||||||
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
`<span class="fart">${mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id)}`+
|
||||||
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
(err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
||||||
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
|
(f.pinned?`<span class="fpin" role="img" title="Pinned" aria-label="Pinned">${ICON.pinOn}</span>`:'')+`</span>`+
|
||||||
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
|
||||||
|
(nbad?`${plural(nbad,'feed')} not updating`
|
||||||
|
:err?esc(f.failing?.reason||'The last check failed')
|
||||||
|
:`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`)+
|
||||||
`</small></div>`+
|
`</small></div>`+
|
||||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
@@ -104,6 +143,7 @@ function renderFeeds(){
|
|||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
|
paintScanning();
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
function selectFeed(id){
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
// the document because the panes are rebuilt every time a feed renders.
|
// the document because the panes are rebuilt every time a feed renders.
|
||||||
|
|
||||||
const PULL = 70; // px down, from the list's top, that counts as a pull
|
const PULL = 70; // px down, from the list's top, that counts as a pull
|
||||||
const SWIPE = 60; // px across that counts as a swipe
|
// A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb
|
||||||
|
// scrolling slightly on the diagonal jumped to the next item by accident (issue #49).
|
||||||
|
const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4);
|
||||||
|
const GAP = 16; // px of page between two items side by side, so they read as two sheets
|
||||||
|
const narrow = () => !!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
const still = () => !!window.matchMedia?.('(prefers-reduced-motion:reduce)')?.matches;
|
||||||
|
|
||||||
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
||||||
|
|
||||||
@@ -31,14 +36,25 @@ function pullShow(dy: number){
|
|||||||
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
|
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A check started by a pull, while its spinner is up. Letting go showed nothing before (issue
|
||||||
|
/// #68), the sidebar's spinner is hidden on a phone, and people pulled again and again.
|
||||||
|
let refreshing = false;
|
||||||
|
|
||||||
function refreshFeed(){
|
function refreshFeed(){
|
||||||
const f = S.feeds.find(x => x.id === S.feed);
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
if(!f && S.feed !== ':all') return;
|
if(!f && S.feed !== ':all') return;
|
||||||
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
|
if(refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
// Outside #list, which a feed's render rebuilds and would take the spinner with it.
|
||||||
|
const spin = document.createElement('div');
|
||||||
|
spin.id = 'pullspin'; spin.setAttribute('role', 'status'); spin.textContent = 'Checking for new items';
|
||||||
|
document.body.append(spin);
|
||||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
.catch(e => toast(e.message, true));
|
.catch(e => toast(e.message, true));
|
||||||
loadEntries();
|
loadEntries();
|
||||||
|
// Up for two seconds at least, so it is seen even when the server answers at once.
|
||||||
|
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('touchstart', ev => {
|
document.addEventListener('touchstart', ev => {
|
||||||
@@ -47,9 +63,15 @@ document.addEventListener('touchstart', ev => {
|
|||||||
const t = ev.touches[0], target = ev.target as Element;
|
const t = ev.touches[0], target = ev.target as Element;
|
||||||
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
|
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
|
||||||
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
|
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
|
||||||
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
if(detail && S.sel && !ownsSwipe(target)){
|
||||||
|
touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
||||||
|
// Safari takes a swipe from the screen's left edge as Back, which leaves the page halfway
|
||||||
|
// through going back to the list. Kept narrower than the reader's padding, so a tap on the
|
||||||
|
// back button is never swallowed with it.
|
||||||
|
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
||||||
|
}
|
||||||
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||||
}, {passive: true});
|
}, {passive: false});
|
||||||
|
|
||||||
document.addEventListener('touchmove', ev => {
|
document.addEventListener('touchmove', ev => {
|
||||||
if(!touch) return;
|
if(!touch) return;
|
||||||
@@ -61,11 +83,74 @@ document.addEventListener('touchmove', ev => {
|
|||||||
pullShow(touch.dy);
|
pullShow(touch.dy);
|
||||||
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
|
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
|
||||||
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
|
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
|
||||||
touch = null; // scrolling the text, not swiping; the first few px
|
slide(0); touch = null; // scrolling the text, not swiping; the first few px
|
||||||
// decide nothing, being mostly jitter
|
// decide nothing, being mostly jitter
|
||||||
|
}else if(Math.abs(touch.dx) > 10){
|
||||||
|
// The reader follows the finger, so it is plain before letting go whether this will move on;
|
||||||
|
// with nothing that way it only gives a little.
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||||
|
const end = touch.dx < 0 && i >= S.entries.length - 1;
|
||||||
|
slide(end ? touch.dx / 3 : touch.dx, 0);
|
||||||
}
|
}
|
||||||
}, {passive: false});
|
}, {passive: false});
|
||||||
|
|
||||||
|
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
||||||
|
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
|
||||||
|
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
||||||
|
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
||||||
|
/// copy of the reader's markup repeats the reader's ids.
|
||||||
|
function beside(dx: number){
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)];
|
||||||
|
const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim');
|
||||||
|
let p = $('#dpeek');
|
||||||
|
if(!p){
|
||||||
|
p = document.createElement('div'); p.id = 'dpeek'; p.inert = true; p.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.append(p);
|
||||||
|
}
|
||||||
|
if(p.dataset.key !== kind + (e?.guid || '')){
|
||||||
|
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
||||||
|
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the reader across by dx over ms, or at once (when following a finger), and what lies
|
||||||
|
/// beside it with it.
|
||||||
|
function slide(dx: number, ms = 160){
|
||||||
|
const d = $('#detail'); if(!d) return;
|
||||||
|
dx = Math.round(dx); // whole pixels, or the seam between the reader and its neighbour blurs
|
||||||
|
const w = d.clientWidth || 1, tr = ms ? `transform ${ms}ms ease-out, opacity ${ms}ms ease-out` : 'none';
|
||||||
|
d.style.transition = tr;
|
||||||
|
d.style.transform = dx ? `translateX(${dx}px)` : '';
|
||||||
|
// A wider screen has the reader as a pane beside the list, with nothing under it to show.
|
||||||
|
if(!narrow()){ d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / w)) : ''; return; }
|
||||||
|
const p = dx ? beside(dx) : $('#dpeek');
|
||||||
|
d.classList.toggle('popping', !!dx && p?.className === 'dim');
|
||||||
|
if(!p) return;
|
||||||
|
p.style.transition = tr;
|
||||||
|
if(p.className === 'dim') p.style.opacity = String(.35 * (1 - Math.abs(dx) / w));
|
||||||
|
else p.style.transform = `translateX(${dx + (p.className === 'prev' ? -w - GAP : w)}px)`;
|
||||||
|
// Gone once the reader is back, unless another drag has already begun.
|
||||||
|
if(!dx){ if(ms) setTimeout(() => { if(!touch) $('#dpeek')?.remove(); }, ms); else p.remove(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reader carries on the way the finger went, from wherever it was let go, and the item
|
||||||
|
/// beside it becomes the reader in its place. Timers, not transitionend, because with reduced
|
||||||
|
/// motion the stylesheet turns transitions off and transitionend never comes.
|
||||||
|
function slideOn(dx: number, go: () => void){
|
||||||
|
const d = $('#detail'), w = d?.clientWidth || 0, dir = Math.sign(dx);
|
||||||
|
if(!narrow()){
|
||||||
|
// A pane beside the list: off one side, and the next item in from the other.
|
||||||
|
slide(dir * w);
|
||||||
|
setTimeout(() => { go(); slide(-dir * w, 0); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const to = dir * (w + (beside(dx).className === 'dim' ? 0 : GAP));
|
||||||
|
const ms = still() ? 0 : Math.round(Math.max(120, 250 * (1 - Math.abs(dx) / (w || 1))));
|
||||||
|
slide(to, ms);
|
||||||
|
setTimeout(() => { go(); slide(0, 0); }, ms);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('touchend', () => {
|
document.addEventListener('touchend', () => {
|
||||||
const g = touch; touch = null;
|
const g = touch; touch = null;
|
||||||
if(!g) return;
|
if(!g) return;
|
||||||
@@ -75,9 +160,11 @@ document.addEventListener('touchend', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Across, mostly sideways, and not a slow drag while selecting text.
|
// Across, mostly sideways, and not a slow drag while selecting text.
|
||||||
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
|
|
||||||
const i = S.entries.findIndex(x => x.guid === S.sel);
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||||
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
|
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
|
||||||
if(i > 0) stepEntry(-1); else showDetail(null);
|
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
|
||||||
|
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
|
||||||
|
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
|
||||||
|
slideOn(g.dx, () => showDetail(null));
|
||||||
});
|
});
|
||||||
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });
|
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });
|
||||||
|
|||||||
112
web/src/items.ts
@@ -20,10 +20,12 @@ async function loadEntries(append?: boolean){
|
|||||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||||
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||||||
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||||
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
|
// The items turned past on the way to it are kept too, so a swipe back still finds them.
|
||||||
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
|
const kept=e=>e.guid===S.sel||turned.has(e.guid);
|
||||||
const open=S.entries.find(e=>e.guid===S.sel);
|
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
|
||||||
if(open) entries=[open,...entries];
|
if(!append && S.sel){
|
||||||
|
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
|
||||||
|
entries=[...gone,...entries];
|
||||||
}
|
}
|
||||||
S.entries = entries;
|
S.entries = entries;
|
||||||
renderEntries();
|
renderEntries();
|
||||||
@@ -63,13 +65,14 @@ function epEl(e){
|
|||||||
el.dataset.guid=e.guid;
|
el.dataset.guid=e.guid;
|
||||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
||||||
|
const name=entryName(e);
|
||||||
el.innerHTML=`
|
el.innerHTML=`
|
||||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||||
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||||
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
|
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
|
||||||
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<span class="t">${esc(e.title||'(untitled)')}</span>
|
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
||||||
<div class="line">${[
|
<div class="line">${[
|
||||||
num&&`<span>${num}</span>`,
|
num&&`<span>${num}</span>`,
|
||||||
left&&`<span>${left}</span>`,
|
left&&`<span>${left}</span>`,
|
||||||
@@ -134,6 +137,25 @@ function kindIcon(enc){
|
|||||||
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||||
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||||
|
|
||||||
|
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
||||||
|
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
||||||
|
/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of
|
||||||
|
/// them. `derived` marks a name that is not a title, which the list sets as text, not heading.
|
||||||
|
function entryName(e): {text: string, derived: boolean}{
|
||||||
|
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
||||||
|
// An inert document: nothing in it loads or runs, where a detached element fetches its images.
|
||||||
|
const words=e.description
|
||||||
|
? (new DOMParser().parseFromString(e.description,'text/html').body.textContent||'').replace(/\s+/g,' ').trim()
|
||||||
|
: '';
|
||||||
|
if(words){
|
||||||
|
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
||||||
|
return {text:cut, derived:true};
|
||||||
|
}
|
||||||
|
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||||
|
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
||||||
|
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
|
||||||
|
}
|
||||||
|
|
||||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||||
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||||
function swapRow(e){
|
function swapRow(e){
|
||||||
@@ -151,7 +173,7 @@ function setRead(e,read){
|
|||||||
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||||
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||||
{method:'POST',body:JSON.stringify({read})})
|
{method:'POST',body:JSON.stringify({read})})
|
||||||
.then(()=>{ w.done=performance.now(); loadFeeds(true); });
|
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||||
@@ -161,13 +183,24 @@ function markRead(e){
|
|||||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEntry(e){
|
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
|
||||||
|
/// in the list until the reader closes or another item is picked from the list. Dropped as each
|
||||||
|
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
|
||||||
|
const turned=new Set<string>();
|
||||||
|
function dropTurned(keep){
|
||||||
|
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
||||||
|
turned.clear();
|
||||||
|
if(!gone.length) return;
|
||||||
|
S.entries=S.entries.filter(x=>!gone.includes(x)); S.total-=gone.length;
|
||||||
|
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEntry(e,turning=false){
|
||||||
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
||||||
// next happens to come along, which left a few read ones in the list for a while.
|
// next happens to come along, which left a few read ones in the list for a while.
|
||||||
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
|
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
|
||||||
if(prev&&prev.read){
|
turned.add(S.sel);
|
||||||
S.entries=S.entries.filter(x=>x!==prev); S.total--;
|
if(!turning) dropTurned(e.guid);
|
||||||
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
|
|
||||||
}
|
}
|
||||||
S.sel=e.guid;
|
S.sel=e.guid;
|
||||||
markRead(e);
|
markRead(e);
|
||||||
@@ -207,8 +240,12 @@ const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
|||||||
function syncTools(e){
|
function syncTools(e){
|
||||||
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||||
// The same icons as the item's own buttons beside its title, so the two never disagree.
|
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
||||||
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
|
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
||||||
|
// side said opposite things (#74). The shape carries it, not the colour.
|
||||||
|
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||||
|
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||||
|
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||||
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||||
if(e){
|
if(e){
|
||||||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||||
@@ -224,12 +261,26 @@ function showDetail(e){
|
|||||||
document.body.classList.toggle('reading',!!e);
|
document.body.classList.toggle('reading',!!e);
|
||||||
syncTools(e);
|
syncTools(e);
|
||||||
if(!e){
|
if(!e){
|
||||||
|
dropTurned(S.sel);
|
||||||
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
|
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
|
||||||
if(files) files.innerHTML='<p class="empty">No files</p>';
|
if(files) files.innerHTML='<p class="empty">No files</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
||||||
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
||||||
|
const encs=e.enclosures.map(encBox).join('');
|
||||||
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
box.innerHTML=detailHtml(e);
|
||||||
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||||
|
$('#dback').onclick=()=>showDetail(null);
|
||||||
|
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||||
|
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||||
|
syncPlayButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
||||||
|
/// previous item beside the reader while it is swiped (gestures.ts).
|
||||||
|
function detailHtml(e){
|
||||||
const encs=e.enclosures.map(encBox).join('');
|
const encs=e.enclosures.map(encBox).join('');
|
||||||
// A phone has no room for the files pane, so the files go above the text there instead.
|
// A phone has no room for the files pane, so the files go above the text there instead.
|
||||||
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||||
@@ -237,28 +288,25 @@ function showDetail(e){
|
|||||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
box.innerHTML=`
|
// description was sanitized server-side with ammonia before it ever reached here
|
||||||
|
return `
|
||||||
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||||||
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
${/* A post without a title starts with its text: its own first words as a heading above
|
||||||
|
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
|
||||||
<div class="dmeta">
|
<div class="dmeta">
|
||||||
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
||||||
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
||||||
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||||
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||||
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
|
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
||||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||||
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
||||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
|
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
|
||||||
</div>
|
</div>
|
||||||
${narrow?encs:''}
|
${narrow?encs:''}
|
||||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
|
||||||
// description was sanitized server-side with ammonia before it ever reached here
|
|
||||||
$('#dback').onclick=()=>showDetail(null);
|
|
||||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
|
||||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
|
||||||
syncPlayButtons();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||||
@@ -273,6 +321,8 @@ function encBox(x){
|
|||||||
: 'Delete file';
|
: 'Delete file';
|
||||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
||||||
|
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||||
|
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||||
if(x.path && !isPlayable(x)){
|
if(x.path && !isPlayable(x)){
|
||||||
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
@@ -280,6 +330,7 @@ function encBox(x){
|
|||||||
<span class="meta" style="flex:1">${size}</span>
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
||||||
${saveBtn}
|
${saveBtn}
|
||||||
|
${shareBtn}
|
||||||
${delBtn}
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -291,18 +342,20 @@ function encBox(x){
|
|||||||
<span class="meta" style="flex:1">${size}</span>
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||||||
${saveBtn}
|
${saveBtn}
|
||||||
|
${shareBtn}
|
||||||
${delBtn}
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
||||||
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
||||||
// through here, which would make ipx a fetch-anything relay.
|
// through here, which would make ipx a fetch-anything relay.
|
||||||
const viewable = !isPlayable(x) && x.state !== 'pending';
|
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
${kindIcon(x)}
|
${kindIcon(x)}
|
||||||
<span class="meta" style="flex:1">${size}</span>
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||||
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
||||||
|
${shareBtn}
|
||||||
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -314,14 +367,13 @@ async function epAction(a: string, e, el, encId?: number){
|
|||||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
try{
|
try{
|
||||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||||
// A pinned item sits at the top of its list (the server sorts it there), so the list is
|
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
|
||||||
// asked for again rather than the row redrawn where it stands.
|
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); loadEntries(); }
|
|
||||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='get'){
|
||||||
if(!enc) return;
|
if(!enc) return;
|
||||||
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||||
toast('Queued: '+(e.title||'item'));
|
toast('Queued: '+entryName(e).text);
|
||||||
}
|
}
|
||||||
if(a==='del'){
|
if(a==='del'){
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
|||||||
177
web/src/native.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/* ---------------- native shell bridge ---------------- */
|
||||||
|
// Inside the iOS or Android app this page is a WebView, and the audio it plays has to come from
|
||||||
|
// the host's own player instead of this element: CarPlay and Android Auto are template surfaces
|
||||||
|
// that cannot render a WebView at all, and the only audio they will control is the host's.
|
||||||
|
//
|
||||||
|
// So the host's player takes over, and the element keeps its face. Everything in player.ts speaks
|
||||||
|
// to `audio` through a small surface -- play, pause, src, currentTime, duration, paused,
|
||||||
|
// readyState, volume, playbackRate, and the events it fires -- so replacing that surface on the
|
||||||
|
// element leaves the player bar, the row buttons, the EQ bars and the keyboard shortcuts working
|
||||||
|
// exactly as they do in a browser, with nothing in player.ts changed.
|
||||||
|
//
|
||||||
|
// In a browser none of this installs and the page is untouched.
|
||||||
|
|
||||||
|
/// How the host is reached. iOS puts a handler on `webkit.messageHandlers`; Android's
|
||||||
|
/// `addJavascriptInterface` gives a plain object with a `postMessage(string)`. Null in a browser,
|
||||||
|
/// which is what switches the whole file off.
|
||||||
|
const ipxHost: ((m: any) => void) | null = (() => {
|
||||||
|
const w = window as any;
|
||||||
|
const ios = w.webkit?.messageHandlers?.ipx;
|
||||||
|
if (ios) return (m: any) => ios.postMessage(m);
|
||||||
|
const android = w.ipxAndroid;
|
||||||
|
if (android?.postMessage) return (m: any) => android.postMessage(JSON.stringify(m));
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (ipxHost) installNativePlayback();
|
||||||
|
|
||||||
|
function installNativePlayback(){
|
||||||
|
const post = ipxHost!;
|
||||||
|
const M = HTMLMediaElement.prototype;
|
||||||
|
const own = (k: string) => Object.getOwnPropertyDescriptor(M, k)!;
|
||||||
|
const realPlay = M.play, realPause = M.pause, realLoad = M.load;
|
||||||
|
// Bound before anything is redefined, because the src setter below has to drop the element's
|
||||||
|
// file without that counting as closing the player: removeAttribute is overridden further down
|
||||||
|
// to mean exactly that, and going through it there switched the shim straight back off.
|
||||||
|
const realRemoveAttribute = audio.removeAttribute.bind(audio);
|
||||||
|
const src = own('src'), currentTime = own('currentTime'), duration = own('duration');
|
||||||
|
const paused = own('paused'), readyState = own('readyState');
|
||||||
|
const volume = own('volume'), playbackRate = own('playbackRate');
|
||||||
|
|
||||||
|
// What the host last told us. `on` is the whole switch: false means this element is playing for
|
||||||
|
// itself, which is still the case for video -- the host plays audio, and a native video layer
|
||||||
|
// under a WebView buys nothing when CarPlay is audio-only either way.
|
||||||
|
const N = {on:false, cur:0, dur:NaN, paused:true, ready:0};
|
||||||
|
|
||||||
|
const fire = (name: string) => audio.dispatchEvent(new Event(name));
|
||||||
|
|
||||||
|
const define = (k: string, d: PropertyDescriptor) =>
|
||||||
|
Object.defineProperty(audio, k, {configurable:true, ...d});
|
||||||
|
|
||||||
|
define('play', {value(){
|
||||||
|
if(!N.on) return realPlay.call(audio);
|
||||||
|
post({t:'play'});
|
||||||
|
// player.ts does audio.play().catch(...) to toast a failure. A failure here arrives as a
|
||||||
|
// message from the host instead, so there is nothing to reject.
|
||||||
|
return Promise.resolve();
|
||||||
|
}});
|
||||||
|
define('pause', {value(){
|
||||||
|
if(!N.on) return realPause.call(audio);
|
||||||
|
post({t:'pause'});
|
||||||
|
}});
|
||||||
|
|
||||||
|
define('src', {
|
||||||
|
get(){ return N.on ? '' : src.get!.call(audio); },
|
||||||
|
set(v){
|
||||||
|
// has-video is set immediately before the src in play(), so it is already right here.
|
||||||
|
if(document.body.classList.contains('has-video')){
|
||||||
|
stop();
|
||||||
|
src.set!.call(audio, v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
N.on = true; N.cur = 0; N.dur = NaN; N.paused = true; N.ready = 0;
|
||||||
|
// Let go of whatever the element was holding, or a video just closed keeps its buffer and
|
||||||
|
// its audio track. removeAttribute alone does not: it takes a load() to act on it.
|
||||||
|
realPause.call(audio);
|
||||||
|
realRemoveAttribute('src');
|
||||||
|
realLoad.call(audio);
|
||||||
|
const e = player.entry, f = player.feed;
|
||||||
|
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
|
||||||
|
title:e ? entryName(e).text : '', feedTitle:feedName(f),
|
||||||
|
artwork:(e && e.image) || feedArt(f) || null,
|
||||||
|
// Where the host starts is not this: the seek to where you left off is player.ts's, on
|
||||||
|
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
|
||||||
|
// display before the file has loaded.
|
||||||
|
position:(e && e.position) || 0, duration:(e && e.duration) || null,
|
||||||
|
rate:audio.playbackRate, volume:audio.volume});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
define('currentTime', {
|
||||||
|
get(){ return N.on ? N.cur : currentTime.get!.call(audio); },
|
||||||
|
set(v){
|
||||||
|
if(!N.on){ currentTime.set!.call(audio, v); return; }
|
||||||
|
N.cur = v;
|
||||||
|
post({t:'seek', to:v});
|
||||||
|
// The clock and the scrubber move now rather than at the host's next tick, which is what
|
||||||
|
// makes the 15 and 30 second keys feel like they did.
|
||||||
|
fire('timeupdate');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
define('duration', {get(){ return N.on ? N.dur : duration.get!.call(audio); }});
|
||||||
|
define('paused', {get(){ return N.on ? N.paused : paused.get!.call(audio); }});
|
||||||
|
define('readyState', {get(){ return N.on ? N.ready : readyState.get!.call(audio); }});
|
||||||
|
|
||||||
|
define('volume', {
|
||||||
|
get(){ return volume.get!.call(audio); },
|
||||||
|
set(v){ volume.set!.call(audio, v); if(N.on) post({t:'volume', v}); },
|
||||||
|
});
|
||||||
|
define('playbackRate', {
|
||||||
|
get(){ return playbackRate.get!.call(audio); },
|
||||||
|
set(v){ playbackRate.set!.call(audio, v); if(N.on) post({t:'rate', v}); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closing the player is `audio.removeAttribute('src')`, which would otherwise leave the host
|
||||||
|
// playing on with nothing on screen to stop it.
|
||||||
|
define('removeAttribute', {value(name: string){
|
||||||
|
if(name === 'src') stop();
|
||||||
|
return realRemoveAttribute(name);
|
||||||
|
}});
|
||||||
|
|
||||||
|
function stop(){
|
||||||
|
if(!N.on) return;
|
||||||
|
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
|
||||||
|
post({t:'stop'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position belongs to the host. player.ts is emphatic about what a stale write costs -- a player
|
||||||
|
// left paused in another tab once saved its older place over where you had got to -- and a
|
||||||
|
// backgrounded WebView is exactly that tab: frozen, holding a time from minutes ago, while the
|
||||||
|
// host plays on. So the beacon becomes a request for the host to save its own time, and the host
|
||||||
|
// is also the one saving while nothing here is running at all.
|
||||||
|
const beacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
|
||||||
|
navigator.sendBeacon = function(url: string, data?: any){
|
||||||
|
if(N.on && /\/position$/.test(String(url))){ post({t:'position', url:String(url)}); return true; }
|
||||||
|
return beacon ? beacon(url, data) : false;
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// What the host calls back into. On `window` deliberately: the host reaches it by name through
|
||||||
|
// evaluateJavaScript, and a top-level const would work but not obviously.
|
||||||
|
(window as any).ipxNative = {
|
||||||
|
version: 1,
|
||||||
|
on(m: any){
|
||||||
|
if(!N.on) return;
|
||||||
|
switch(m.t){
|
||||||
|
case 'time':
|
||||||
|
N.cur = m.cur;
|
||||||
|
if(m.dur != null) N.dur = m.dur;
|
||||||
|
fire('timeupdate');
|
||||||
|
break;
|
||||||
|
case 'meta':
|
||||||
|
N.dur = m.dur; N.ready = 1;
|
||||||
|
fire('loadedmetadata');
|
||||||
|
break;
|
||||||
|
case 'state':
|
||||||
|
if(m.playing === !N.paused) return;
|
||||||
|
N.paused = !m.playing;
|
||||||
|
fire(m.playing ? 'play' : 'pause');
|
||||||
|
break;
|
||||||
|
case 'ended':
|
||||||
|
N.paused = true;
|
||||||
|
fire('pause');
|
||||||
|
fire('ended');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
N.paused = true;
|
||||||
|
fire('pause');
|
||||||
|
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The host waits for this to know the bridge is in and which build it got: an app newer than the
|
||||||
|
// deployed page would otherwise sit there sending messages nothing answers.
|
||||||
|
post({t:'ready', version:1, rate:audio.playbackRate, volume:audio.volume});
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|||||||
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||||
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
||||||
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
|
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
|
||||||
$('#ptitle').textContent=e.title||'(untitled)';
|
$('#ptitle').textContent=entryName(e).text;
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
$('#pfeed').textContent=f?(f.title||f.id):'';
|
$('#pfeed').textContent=f?(f.title||f.id):'';
|
||||||
$('#player').classList.add('on');
|
$('#player').classList.add('on');
|
||||||
@@ -45,8 +45,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|||||||
function mediaSession(e,f){
|
function mediaSession(e,f){
|
||||||
if(!('mediaSession' in navigator)) return;
|
if(!('mediaSession' in navigator)) return;
|
||||||
navigator.mediaSession.metadata=new MediaMetadata({
|
navigator.mediaSession.metadata=new MediaMetadata({
|
||||||
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||||
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
|
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
|
||||||
});
|
});
|
||||||
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||||
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||||
@@ -141,7 +141,7 @@ function stepEntry(by){
|
|||||||
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
||||||
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
||||||
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||||
selectEntry(e);
|
selectEntry(e,true);
|
||||||
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
}
|
}
|
||||||
function stepFeed(by){
|
function stepFeed(by){
|
||||||
@@ -191,8 +191,7 @@ function keysModal(){
|
|||||||
['Anywhere'],
|
['Anywhere'],
|
||||||
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||||
];
|
];
|
||||||
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
||||||
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
|
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
/* ---------------- theme ---------------- */
|
/* ---------------- theme ---------------- */
|
||||||
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on
|
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in a
|
||||||
// the account, so it follows you to another browser or computer. The server writes it onto the
|
// cookie, so each device has its own: Glass on a phone, Dracula on a desktop (issue #69). The
|
||||||
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The
|
// server reads it and writes it onto the page's <html> tag (data-theme, data-choice) so the page
|
||||||
|
// is drawn in it from the start. The
|
||||||
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||||
// the system, so no palette is written twice.
|
// the system, so no palette is written twice.
|
||||||
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
modern: {name: 'Modern', modes: true},
|
|
||||||
classic: {name: 'Classic, the 2004 Mac app', modes: false},
|
|
||||||
dracula: {name: 'Dracula', modes: true},
|
|
||||||
material: {name: 'Material', modes: true},
|
|
||||||
adwaita: {name: 'Adwaita', modes: true},
|
adwaita: {name: 'Adwaita', modes: true},
|
||||||
|
catppuccin: {name: 'Catppuccin', modes: true},
|
||||||
|
classic: {name: 'Classic', modes: false},
|
||||||
|
dracula: {name: 'Dracula', modes: true},
|
||||||
flatremix: {name: 'Flat Remix', modes: true},
|
flatremix: {name: 'Flat Remix', modes: true},
|
||||||
paper: {name: 'Paper', modes: false},
|
glass: {name: 'Glass', modes: true},
|
||||||
|
gruvbox: {name: 'Gruvbox', modes: true},
|
||||||
|
contrast: {name: 'High contrast', modes: true},
|
||||||
|
material: {name: 'Material', modes: true},
|
||||||
|
modern: {name: 'Modern', modes: true},
|
||||||
nordic: {name: 'Nordic', modes: true},
|
nordic: {name: 'Nordic', modes: true},
|
||||||
|
paper: {name: 'Paper', modes: false},
|
||||||
|
solarized: {name: 'Solarized', modes: true},
|
||||||
};
|
};
|
||||||
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||||
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||||
@@ -20,7 +26,7 @@ const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'],
|
|||||||
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
|
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
const theme = {name: 'modern', mode: 'dark'};
|
const theme = {name: 'modern', mode: 'dark'};
|
||||||
|
|
||||||
/// `save` for a choice made in Settings, which goes to the account; not for applying one.
|
/// `save` for a choice made in Settings, which goes to this browser's cookie; not for applying one.
|
||||||
function setTheme(name = theme.name, mode = theme.mode, save = false){
|
function setTheme(name = theme.name, mode = theme.mode, save = false){
|
||||||
theme.name = THEMES[name] ? name : 'modern';
|
theme.name = THEMES[name] ? name : 'modern';
|
||||||
theme.mode = MODES[mode] ? mode : 'dark';
|
theme.mode = MODES[mode] ? mode : 'dark';
|
||||||
@@ -30,27 +36,26 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
|
|||||||
// A theme with one palette has it whatever the mode; both of those are light.
|
// A theme with one palette has it whatever the mode; both of those are light.
|
||||||
root.dataset.mode = !both ? 'light'
|
root.dataset.mode = !both ? 'light'
|
||||||
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||||
|
// The tab's icon in the same variant as the logo on the page.
|
||||||
|
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
|
||||||
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||||
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||||
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||||
if(save) saveTheme();
|
if(save) saveTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came,
|
/// Kept a year, for the whole site: the admin page is drawn in it too.
|
||||||
/// several at once, a quick run through the list could reach the server out of order and
|
|
||||||
/// leave the account on a theme passed on the way.
|
|
||||||
let themeSaving = Promise.resolve();
|
|
||||||
function saveTheme(){
|
function saveTheme(){
|
||||||
themeSaving = themeSaving
|
document.cookie = `ipx_theme=${theme.name}.${theme.mode}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
|
|
||||||
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
|
|
||||||
}
|
}
|
||||||
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||||
(() => {
|
(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
|
// Without a cookie, the server sent the theme the account kept from before; this browser takes
|
||||||
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the
|
// it as its own, once, so nobody has to choose again.
|
||||||
// account, goes up to it once, so nobody has to choose again.
|
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie));
|
||||||
|
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
|
||||||
|
// becomes its cookie, once.
|
||||||
let name: string | null = null, mode: string | null = null;
|
let name: string | null = null, mode: string | null = null;
|
||||||
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
||||||
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>
|
|||||||
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||||
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||||
const ICON={
|
const ICON={
|
||||||
|
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||||
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||||
@@ -26,7 +27,6 @@ const ICON={
|
|||||||
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||||
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||||
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
||||||
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
|
|
||||||
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
||||||
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
||||||
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
||||||
@@ -40,7 +40,6 @@ const ICON={
|
|||||||
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
||||||
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
||||||
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||||||
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
|
|
||||||
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||||||
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||||||
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||||||
@@ -94,6 +93,27 @@ async function copyText(text,btn){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
|
||||||
|
/// Asks first when the address looks like it carries your own access to a paid or private
|
||||||
|
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
|
||||||
|
/// gets it your subscription (issue #48).
|
||||||
|
///
|
||||||
|
/// ponytail: a guess from the address. A private feed whose key has another name is shared
|
||||||
|
/// without asking; add its pattern here when one turns up.
|
||||||
|
async function share(title: string, url: string, btn?){
|
||||||
|
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
|
||||||
|
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
|
||||||
|
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
||||||
|
if(navigator.share){
|
||||||
|
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
||||||
|
try{ await navigator.share({title, url}); }
|
||||||
|
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyText(url,btn);
|
||||||
|
toast('Link copied');
|
||||||
|
}
|
||||||
|
|
||||||
function toast(msg: string, bad?: boolean){
|
function toast(msg: string, bad?: boolean){
|
||||||
const t=document.createElement('div');
|
const t=document.createElement('div');
|
||||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||||
@@ -125,9 +145,15 @@ const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>
|
|||||||
function tileHTML(name,cls){
|
function tileHTML(name,cls){
|
||||||
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||||||
}
|
}
|
||||||
|
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
|
||||||
|
/// no publisher's server asked on every visit. It began with http-only artwork, which the https
|
||||||
|
/// page could not load (#90).
|
||||||
|
function artSrc(url: string){
|
||||||
|
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
|
||||||
|
}
|
||||||
function artHTML(url: string | null, name: string, cls?: string){
|
function artHTML(url: string | null, name: string, cls?: string){
|
||||||
return url
|
return url
|
||||||
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||||
: tileHTML(name,cls);
|
: tileHTML(name,cls);
|
||||||
}
|
}
|
||||||
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||||||
@@ -136,7 +162,7 @@ function folderArt(f,kids){
|
|||||||
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||||||
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||||||
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||||
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
||||||
|
|||||||