Compare commits
88 Commits
v1.3.0
...
directory-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be8eb5cdf | |||
| e1291c1627 | |||
| a11e3b6367 | |||
| cb4ee5a570 | |||
| c4878731c6 | |||
| 21fa0ae476 | |||
| aa430f6d50 | |||
| df643536c0 | |||
| 543d35f02c | |||
| 0de0fb4332 | |||
| 79b496bdfb | |||
| a20fade8ad | |||
| daf95a25cd | |||
| 91de1114d8 | |||
| 37c08195f1 | |||
| d1149e1236 | |||
| 46fe0dddbf | |||
| d35b8054b4 | |||
| 3bfd8f848b | |||
| fc04a741a6 | |||
| 80f12cda70 | |||
| 4f7dab9c04 | |||
| cc3b8742bc | |||
| c99f481124 | |||
| a20062d0f7 | |||
| 6d71613e19 | |||
| f398211fd6 | |||
| 708b69c083 | |||
| 62a2456dde | |||
| ec3dedf113 | |||
| 511246acb2 | |||
| ccc4766b03 | |||
| 78b81e43aa | |||
| 4af12363bc | |||
| 84b4203daf | |||
| 0696583547 | |||
| 6ec2c35291 | |||
| c8afcec73d | |||
| 5bad68670a | |||
| d87d024096 | |||
| 44afc08b9d | |||
| a21472730e | |||
| 995ee05557 | |||
| 7af6153d18 | |||
| c1d8983ca1 | |||
| 4c1b78408f | |||
| d850c02501 | |||
| 363a5a122d | |||
| 3d9b45ddd7 | |||
| b54b47897b | |||
| b89c371662 | |||
| 6daab6989e | |||
| 187729a278 | |||
| 3df7c974e8 | |||
| 59aa00e07c | |||
| b4c0048f68 | |||
| d5ffe5d042 | |||
| d16460267f | |||
| 21c786f48a | |||
| 3a8d564eb7 | |||
| f9246129f2 | |||
| 5cf7a28c44 | |||
| ac3808fe97 | |||
| 341d880e13 | |||
| cb4407b19d | |||
| 7f61250852 | |||
| c90a66a24f | |||
| 692a510217 | |||
| 2c4ba7896e | |||
| 36a820f283 | |||
| 41fc2e3933 | |||
| 4eba746ba4 | |||
| ce78599ab4 | |||
| 5130f754cc | |||
| 2077ea5810 | |||
| 026fbd1622 | |||
| 1aef6ba421 | |||
| 9a41641eaa | |||
| 33eb1f4db2 | |||
| 3b48f51a56 | |||
| 55b0192df5 | |||
| e07d27d9f2 | |||
| 3532710b3d | |||
| b56c97259d | |||
| 1025416181 | |||
| 894e5e4711 | |||
| 0e21195e8d | |||
| 3b41abd860 |
@@ -5,5 +5,18 @@
|
||||
"Bash(env)",
|
||||
"mcp__outline__read_document"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.rs) rustfmt --edition 2024 \"$f\" 2>/dev/null || true;; esac"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,11 @@ Q=.claude/skills/ipx-prod-check/query.py
|
||||
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
||||
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
||||
$Q traces '<TraceQL>' [since] # slowest first
|
||||
$Q spans '<TraceQL>' [since] # the matching spans, each with its own time, slowest first
|
||||
$Q trace <trace id> # one trace as a tree, with its log lines
|
||||
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
|
||||
$Q flame '<TraceQL>' [since] [out.svg] # where the matching spans' time went, summed over up to
|
||||
# 100 traces: a flame graph, and out.svg.folded for speedscope
|
||||
```
|
||||
|
||||
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||
@@ -109,6 +112,10 @@ from 2026-10-11 15:15 UTC. All gauges:
|
||||
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
|
||||
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
|
||||
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
|
||||
| `ipx_signals_judged_total{user,key}`, `ipx_signals_hits_total{user,key}` | counters: items Jev judged for Signals, and Signals they fit, by person and whose key paid (`server` or `own`, #278) (#254) |
|
||||
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"rate_limited"\|"error"}`, `ipx_jev_seconds_total`, `ipx_jev_paced_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended; their seconds, the wait for a turn included; and that wait alone. `malformed` is ipx's request at fault (#257); `rate_limited` is a 429 from TypeSafe, whose limits are 80 calls and 100,000 input tokens a second, which ipx paces itself under at 60 and 80,000 (#277) |
|
||||
| `ipx_jev_tokens_total{kind="input"\|"output",user,key}` | counter: tokens TypeSafe counted, as its answers say, by person and whose key paid: what Signals, icon picks and key checks cost (#275, #278). A call shared on the server's key is split by each person's share of its questions |
|
||||
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
|
||||
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
||||
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||
|
||||
@@ -155,18 +162,32 @@ a count says something happened, the lines and traces say why.
|
||||
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
|
||||
the real queue (#113): files a scan will download on its own. Back-catalogue files are
|
||||
`held`, listed but not counted, so it is usually 0 or a handful.
|
||||
5. **Slow and failed traces.** All three services' (filter by `resource.service.name` for one).
|
||||
5. **Slow and failed traces.** Look at ipx's requests as spans, not traces: a trace started by a
|
||||
tap in the page also holds that page's live updates and any audio it streamed, minutes long,
|
||||
so a list of traces over a few seconds is nothing but those. `spans` lists each matching span
|
||||
with its own time and route, slowest first:
|
||||
```
|
||||
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
|
||||
R='resource.deployment.environment.name="production" && resource.service.name="ipx"'
|
||||
$Q spans "{$R && span.http.route != \"\" && span.http.route != \"/api/events\" && span.http.route !~ \"/media/.*\" && duration > 1s} | select(span.http.route)"
|
||||
$Q spans "{$R && name=\"step\" && duration > 500ms} | select(span.of)"
|
||||
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||
$Q trace <id>
|
||||
```
|
||||
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
|
||||
A web request over a second is worth a look; the trace shows whether the time is in the
|
||||
handler or a scan it waited on.
|
||||
Group the first by hour and by route (`awk '{print substr($3,1,2), $NF}' | sort | uniq -c`),
|
||||
then lay the hours beside Tower's (the Tower section): load over 100 with processes blocked
|
||||
and memory under 4 GB is the host stalling, and every request of those minutes is slow with
|
||||
it, ipx's queries included. On 2026-10-18 that was 120 of 130 requests over a second, up to 43 s.
|
||||
What is left at quiet hours is ipx's own: open its trace and see which step holds the time. A
|
||||
request far longer than its steps added up waited outside them, for a connection or a thread.
|
||||
The second line finds steps over half a second wherever they ran; a step slow only once, first
|
||||
thing after hours idle, is Postgres reading from disk what it no longer had in memory (All
|
||||
Subscriptions' `entries_in`, a second where it is 60 ms warm).
|
||||
|
||||
Steps are `step` spans named by `of` (#187): an item list's `entries_in`, `sanitize`,
|
||||
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||
`feed` or `fetch` span holding most of it (`$Q spans '{... && name="feed" && duration > 10s}'`),
|
||||
or a `download` far slower than its size explains.
|
||||
|
||||
Steps are `step` spans named by `of` (#187): an item list's `entries_in` (with the list asked for: `filter`, `sort`, `dir`, `offset`, `limit`, `search`), `sanitize`,
|
||||
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
|
||||
`missing_files`, `prune_abandoned_failures`, `reap_candidates`, `clean_directory` (once a day
|
||||
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
|
||||
@@ -194,6 +215,8 @@ a count says something happened, the lines and traces say why.
|
||||
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_signals_last_pass_age_seconds{job="ipx"}[$range]))' 24h
|
||||
```
|
||||
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
|
||||
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
||||
@@ -210,6 +233,9 @@ a count says something happened, the lines and traces say why.
|
||||
where the worker's time went (reap was most of it until #193). Web requests in flight near
|
||||
the pool's max mean the pool is what they wait on. Live updates missed mean a page showed stale
|
||||
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
|
||||
Any `rate_limited` Jev call means the pacing is not holding ipx under TypeSafe's limits (#277); each
|
||||
`jev` span has `paced_ms`, its wait for a turn. Any `malformed` Jev call is ipx asking wrongly (#257); `key_refused` is someone's key; the Signals
|
||||
judge over 10 minutes since a pass is stuck on Jev: its `signals` trace shows which `jev` call.
|
||||
|
||||
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
|
||||
```
|
||||
@@ -253,7 +279,7 @@ to see where the time went, Caddy's span with ipx's inside it. A slow page with
|
||||
Caddy or the network; a long one is ipx.
|
||||
|
||||
```sh
|
||||
$Q traces '{resource.service.name="ipx-web" && duration > 2s}'
|
||||
$Q spans '{resource.service.name="ipx-web" && span.url.path != "/api/events" && span.url.path !~ "/media/.*" && duration > 2s} | select(span.url.path)'
|
||||
$Q traces '{resource.service.name="ipx-web" && status = error}'
|
||||
```
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
query.py logs '<LogQL log query>' [since] lines, newest first
|
||||
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
|
||||
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
||||
query.py spans '<TraceQL query>' [since] the matching spans, each with its own time
|
||||
query.py trace <trace id> one trace's spans, as a tree
|
||||
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
|
||||
query.py flame '<TraceQL query>' [since] [out.svg] where the matching spans' time
|
||||
went, summed over up to 100 traces, as a
|
||||
flame graph (and out.svg.folded)
|
||||
|
||||
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
|
||||
host, so each call runs a throwaway alpine container on the monitoring project's network.
|
||||
"""
|
||||
import json, subprocess, sys, time, urllib.parse
|
||||
import base64, html, json, subprocess, sys, time, urllib.parse, zlib
|
||||
|
||||
NET = "monitoring_default"
|
||||
|
||||
@@ -23,11 +27,108 @@ def fetch(url):
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def fetch_many(urls):
|
||||
# One container for them all: a docker run apiece is about a second, a hundred traces a minute and a half.
|
||||
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "sh", "-c",
|
||||
'for u; do wget -qO- "$u"; echo; echo @@; done', "--", *urls],
|
||||
capture_output=True, text=True, timeout=600)
|
||||
return [json.loads(part) for part in out.stdout.split("\n@@\n") if part.strip()]
|
||||
|
||||
|
||||
def trace_spans(r):
|
||||
return [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||
|
||||
|
||||
def span_hex(sid):
|
||||
# A trace's span ids are base64 and the search's are hex.
|
||||
return sid if len(sid) == 16 and all(c in "0123456789abcdef" for c in sid) else base64.b64decode(sid).hex()
|
||||
|
||||
|
||||
def label(sp):
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
|
||||
return f"step:{attrs['of']}" if sp["name"] == "step" and attrs.get("of") else sp["name"]
|
||||
|
||||
|
||||
def fold(spans, roots):
|
||||
"""Self time in µs for each stack under the spans in `roots` (hex ids), identical stacks summed.
|
||||
|
||||
shortcut: only as fine as the spans; a route's time outside its `step`s is its own frame. Add
|
||||
steps in ipx where that is large. Children run at once (try_join) can add up past their
|
||||
parent, so a frame's self time is floored at 0.
|
||||
"""
|
||||
kids, stacks = {}, {}
|
||||
for sp in spans:
|
||||
kids.setdefault(span_hex(sp.get("parentSpanId") or "0" * 16), []).append(sp)
|
||||
|
||||
def walk(sp, path):
|
||||
path = f"{path};{label(sp)}" if path else label(sp)
|
||||
dur = lambda s: (int(s["endTimeUnixNano"]) - int(s["startTimeUnixNano"])) // 1000
|
||||
cs = kids.get(span_hex(sp["spanId"]), [])
|
||||
stacks[path] = stacks.get(path, 0) + max(0, dur(sp) - sum(dur(c) for c in cs))
|
||||
for c in cs:
|
||||
walk(c, path)
|
||||
|
||||
for sp in spans:
|
||||
if span_hex(sp["spanId"]) in roots:
|
||||
walk(sp, "")
|
||||
return stacks
|
||||
|
||||
|
||||
def check_fold():
|
||||
def sp(sid, parent, name, start, end, of=None):
|
||||
attrs = [{"key": "of", "value": {"stringValue": of}}] if of else []
|
||||
return {"spanId": sid, "parentSpanId": parent, "name": name, "attributes": attrs,
|
||||
"startTimeUnixNano": str(start * 1000), "endTimeUnixNano": str(end * 1000)}
|
||||
a, b, c, d = (f"{i:016x}" for i in range(1, 5))
|
||||
spans = [sp(a, "", "GET /x", 0, 100), sp(b, a, "step", 0, 60, "q"), sp(c, a, "step", 0, 60, "q"),
|
||||
sp(d, b, "inner", 0, 10)]
|
||||
assert fold(spans, {a}) == {"GET /x": 0, "GET /x;step:q": 110, "GET /x;step:q;inner": 10}, fold(spans, {a})
|
||||
assert fold(spans, {b}) == {"step:q": 50, "step:q;inner": 10}
|
||||
# base64 ids, as a trace sends them, match the search's hex.
|
||||
assert span_hex(base64.b64encode(bytes.fromhex(a)).decode()) == a
|
||||
print("fold: ok")
|
||||
|
||||
|
||||
def flame_svg(stacks, n, title):
|
||||
tree = {} # name -> [total, children]
|
||||
for path, us in stacks.items():
|
||||
node = tree
|
||||
for part in path.split(";"):
|
||||
node = node.setdefault(part, [0, {}])
|
||||
node[0] += us
|
||||
node = node[1]
|
||||
total = sum(v[0] for v in tree.values()) or 1
|
||||
width, row, rects = 1200, 18, []
|
||||
|
||||
def draw(node, x, depth, trail):
|
||||
for name, (us, sub) in sorted(node.items()):
|
||||
w = us / total * width
|
||||
stack = f"{trail};{name}" if trail else name
|
||||
hue = 10 + zlib.crc32(name.encode()) % 45
|
||||
tip = f"{stack}\n{us / 1000:.0f} ms in all, {100 * us / total:.1f}%, {us / 1000 / n:.1f} ms a request"
|
||||
text = html.escape(name[: int(w / 7)]) if w > 30 else ""
|
||||
rects.append(f'<g><title>{html.escape(tip)}</title><rect x="{x:.1f}" y="{30 + depth * row}" '
|
||||
f'width="{max(w - 0.5, 0.1):.1f}" height="{row - 1}" fill="hsl({hue},85%,62%)"/>'
|
||||
f'<text x="{x + 3:.1f}" y="{30 + depth * row + 13}">{text}</text></g>')
|
||||
draw(sub, x, depth + 1, stack)
|
||||
x += w
|
||||
|
||||
draw(tree, 0, 0, "")
|
||||
depth = max(p.count(";") for p in stacks) + 1 if stacks else 1
|
||||
head = html.escape(f"{title}: {n} request(s), {total / 1000 / n:.1f} ms each on average")
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{40 + depth * row}" '
|
||||
f'font-family="monospace" font-size="11"><rect width="100%" height="100%" fill="#fff"/>'
|
||||
f'<text x="4" y="18" font-size="13">{head}</text>{"".join(rects)}</svg>')
|
||||
|
||||
|
||||
def seconds(since):
|
||||
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["flame", "--check"]:
|
||||
return check_fold()
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
kind, q = sys.argv[1], sys.argv[2]
|
||||
@@ -58,6 +159,21 @@ def main():
|
||||
name = t.get("rootTraceName") or "(from the page)"
|
||||
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
|
||||
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
||||
elif kind == "spans":
|
||||
# The spans that matched, each with its own time: a trace's length says nothing about a
|
||||
# slow request in it when the trace also holds a page's live updates or an audio stream.
|
||||
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=200&spss=20")
|
||||
rows = []
|
||||
for t in r.get("traces", []):
|
||||
for ss in t.get("spanSets") or [t.get("spanSet") or {}]:
|
||||
for sp in ss.get("spans", []):
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
|
||||
what = attrs.get("http.route") or attrs.get("of") or attrs.get("url.path") or sp.get("name") or ""
|
||||
rows.append((int(sp.get("durationNanos", 0)) / 1e6, int(sp["startTimeUnixNano"]), t["traceID"], what))
|
||||
for ms, ts, tid, what in sorted(rows, reverse=True):
|
||||
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(ts / 1e9))
|
||||
print(f"{ms:>8.0f}ms {when}Z {tid} {what}")
|
||||
print(f"-- {len(rows)} span(s)", file=sys.stderr)
|
||||
elif kind == "prom":
|
||||
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
|
||||
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")
|
||||
@@ -66,10 +182,24 @@ def main():
|
||||
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")}
|
||||
print(f"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
|
||||
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||
elif kind == "flame":
|
||||
out = sys.argv[4] if len(sys.argv) > 4 else "flame.svg"
|
||||
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100&spss=20")
|
||||
found = r.get("traces", [])
|
||||
roots = {sp["spanID"] for t in found for ss in t.get("spanSets") or [t.get("spanSet") or {}]
|
||||
for sp in ss.get("spans", [])}
|
||||
stacks = {}
|
||||
for tr in fetch_many([f"http://tempo:3200/api/traces/{t['traceID']}" for t in found]):
|
||||
for path, us in fold(trace_spans(tr), roots).items():
|
||||
stacks[path] = stacks.get(path, 0) + us
|
||||
n = len(roots) or 1
|
||||
with open(out + ".folded", "w") as f:
|
||||
f.writelines(f"{path} {us}\n" for path, us in sorted(stacks.items()))
|
||||
with open(out, "w") as f:
|
||||
f.write(flame_svg(stacks, n, q))
|
||||
print(f"-- {len(roots)} span(s) in {len(found)} trace(s): {out}, {out}.folded", file=sys.stderr)
|
||||
elif kind == "trace":
|
||||
r = fetch(f"http://tempo:3200/api/traces/{q}")
|
||||
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||
spans = trace_spans(fetch(f"http://tempo:3200/api/traces/{q}"))
|
||||
kids = {}
|
||||
for sp in spans:
|
||||
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
|
||||
|
||||
3
.git-blame-ignore-revs
Normal file
3
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,3 @@
|
||||
# Formatting only: `git config blame.ignoreRevsFile .git-blame-ignore-revs` makes blame skip these.
|
||||
# rustfmt adopted
|
||||
692a5102179b958b5d15cecc4f5e6f16b2955b76
|
||||
26
.githooks/pre-commit
Executable file
26
.githooks/pre-commit
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
|
||||
# checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the
|
||||
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory,
|
||||
# and both use ipx-feed. A commit to the page alone runs nothing here.
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACMD)
|
||||
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
|
||||
if ! cargo fmt --all --check >/dev/null 2>&1; then
|
||||
echo "pre-commit: the Rust is not formatted; run cargo fmt --all" >&2
|
||||
exit 1
|
||||
fi
|
||||
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
|
||||
pkgs="ipx-feed ipx-directory ipx"
|
||||
elif echo "$staged" | grep -q '^crates/directory/'; then
|
||||
pkgs="ipx-directory ipx"
|
||||
elif echo "$staged" | grep -q '^crates/ipx/'; then
|
||||
pkgs="ipx"
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
args=$(for p in $pkgs; do printf ' -p %s' "$p"; done)
|
||||
# shellcheck disable=SC2086 # one word a flag
|
||||
if ! cargo clippy $args --all-targets -q -- -D warnings; then
|
||||
echo "pre-commit: clippy has warnings in $pkgs; fix them, or allow one where it is wrong, with a comment saying why" >&2
|
||||
exit 1
|
||||
fi
|
||||
47
CLAUDE.md
47
CLAUDE.md
@@ -86,6 +86,11 @@ Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own r
|
||||
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
|
||||
container on another network, as cloudflared's are (#209).
|
||||
|
||||
The three containers resolve names through Cloudflare (`dns: [1.1.1.1, 1.0.0.1]` in the compose
|
||||
file), not the router: the LAN's DNS is NextDNS, which answers for a domain it filters with its own
|
||||
block page, and ipx then logged a feed as failing with `invalid peer certificate: UnknownIssuer`
|
||||
(tomrenner.com, 2026-10-17). Docker's own DNS still answers for the services' names first.
|
||||
|
||||
**Name the service.** A bare `up -d` recreates every container in the project, which once the
|
||||
split lands means the frontend and directory too. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||
|
||||
@@ -171,13 +176,17 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||
cargo fmt --all --check # rustfmt, as rustfmt.toml says; the pre-commit hook refuses a commit without it
|
||||
cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
|
||||
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
|
||||
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
|
||||
cargo test # parsing, filters, retention, schedules, SQL, per-user state
|
||||
npx tsc -p . # type-checks web/src
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/contrast.js # every theme's palette against WCAG AA
|
||||
npx playwright test # 66 browser tests against a real daemon on fixture feeds
|
||||
npx playwright test # the browser tests, against a real daemon on fixture feeds
|
||||
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||
```
|
||||
|
||||
@@ -189,10 +198,34 @@ session was stopped. So:
|
||||
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
||||
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
|
||||
threads twice over, wait for it to come down before starting another.
|
||||
* Build only the crate the change is in (`cargo build -p ipx-directory`), and run only its tests
|
||||
until the change is done; the whole workspace's once, at the end.
|
||||
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
|
||||
|
||||
| changed | run |
|
||||
|---|---|
|
||||
| `web/` alone | the three page checks; `npx playwright test -g '<what changed>'`; no cargo |
|
||||
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
|
||||
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
|
||||
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
|
||||
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
|
||||
|
||||
The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end
|
||||
of a change to the page or the API; never `cargo test` for the workspace in one command.
|
||||
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
|
||||
`/mnt/user` (FUSE) once took the load to 248 on its own.
|
||||
* **Memory is the tighter limit.** Tower has 31 GB, no swap, and the containers leave about 4-7
|
||||
GB. A build past that stalls everything on page faults: load 160 with the CPU idle and the
|
||||
disks quiet, many processes blocked (`node_procs_blocked`, `node_vmstat_pgmajfault`). On
|
||||
2026-10-18 linking a 431 MB test binary did it for 54 minutes. So `[profile.dev]` keeps only
|
||||
line tables and dependencies no debug info; keep it that way, and look at `MemAvailable` beside
|
||||
the load.
|
||||
|
||||
The Rust is formatted by rustfmt (`rustfmt.toml`) and kept free of clippy's warnings. Claude Code
|
||||
formats each `.rs` file it edits (a hook in `.claude/settings.json`), and the pre-commit hook refuses
|
||||
a commit touching Rust that is unformatted or has a clippy warning. A clone turns on the hook once: `git config core.hooksPath .githooks`; and `git config blame.ignoreRevsFile
|
||||
.git-blame-ignore-revs` keeps blame past the commit that first formatted everything. A clippy
|
||||
suggestion is not always the same code: on adopting it, `fold(false, |any, id| remove(id) || any)`
|
||||
was offered as `any(...)`, which stops at the first and would have removed one feed of many. A lint
|
||||
that is wrong for a line gets `#[allow(clippy::...)]` there with a comment saying why.
|
||||
|
||||
Things about the browser suite that have cost time:
|
||||
|
||||
@@ -363,6 +396,10 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
||||
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||
issue left open.
|
||||
|
||||
## Styleguide
|
||||
|
||||
* Best effort to adhere to the rust style guide found at /src/styleguide/rustguide.md
|
||||
|
||||
<!-- rtk-instructions v2 -->
|
||||
# Command output
|
||||
|
||||
@@ -372,4 +409,4 @@ normally, and batch related commands into one call to avoid extra turns.
|
||||
Truncated results state their recovery path in their own output. Re-run a
|
||||
command as `rtk proxy <cmd>` only when its result is unusable: empty when
|
||||
output was clearly expected, contradicting its exit code, or garbled.
|
||||
<!-- /rtk-instructions -->
|
||||
<!-- /rtk-instructions -->
|
||||
|
||||
94
Cargo.lock
generated
94
Cargo.lock
generated
@@ -40,9 +40,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.1.4"
|
||||
version = "4.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
||||
checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"html5ever",
|
||||
@@ -789,9 +789,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
|
||||
dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
@@ -800,9 +800,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -1437,9 +1437,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1555,12 +1555,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1859,12 +1860,11 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"atom_syndication",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -1877,9 +1877,7 @@ dependencies = [
|
||||
"opentelemetry",
|
||||
"opml",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"reqwest",
|
||||
"rss",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1896,7 +1894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipx-directory"
|
||||
version = "1.2.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
@@ -1932,12 +1930,16 @@ dependencies = [
|
||||
"anyhow",
|
||||
"atom_syndication",
|
||||
"chrono",
|
||||
"http",
|
||||
"hyper-util",
|
||||
"opml",
|
||||
"pulldown-cmark",
|
||||
"quick-xml 0.42.0",
|
||||
"reqwest",
|
||||
"rss",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
@@ -2526,9 +2528,9 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
@@ -2985,9 +2987,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"serde",
|
||||
@@ -2995,9 +2997,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -3005,9 +3007,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
@@ -3015,9 +3017,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -3130,6 +3132,24 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
@@ -3544,9 +3564,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.44"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -3608,9 +3628,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -4279,9 +4299,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
@@ -4291,9 +4311,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -5070,9 +5090,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
@@ -5091,9 +5111,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -25,7 +25,10 @@ opentelemetry = { version = "0.33", default-features = false, features = ["trace
|
||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
|
||||
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opml = "1.1.6"
|
||||
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
|
||||
mimalloc = "0.1.52"
|
||||
http = "1.5.0"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy"] }
|
||||
percent-encoding = "2.3.2"
|
||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
|
||||
@@ -42,3 +45,12 @@ tracing = "0.1.44"
|
||||
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
url = "2.5.8"
|
||||
|
||||
# Backtraces keep file and line; the full debug info made a 431 MB test binary whose link ran
|
||||
# Tower, which has no swap, out of memory and stalled it for most of an hour (2026-10-18).
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Nobody steps through dependencies.
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
@@ -47,7 +47,9 @@ cargo build --release
|
||||
./target/release/ipx daemon
|
||||
```
|
||||
|
||||
The first start creates **admin / ipodderx**. Sign in at `/login`, then change it:
|
||||
The first start creates **admin** with a random password, written to `admin-password` beside
|
||||
config.toml (readable by ipx's user alone; the log says where, never what it is). Set
|
||||
`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it:
|
||||
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user passwd admin
|
||||
|
||||
@@ -10,6 +10,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- A WordPress.com site that answers its feed with a browser check (HTTP 403) is read from WordPress.com's API instead of failing (#317).
|
||||
|
||||
### Security
|
||||
|
||||
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
||||
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
|
||||
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
|
||||
- A server's count of a feed's subscribers is taken up to a million. Two servers sending the largest number there is made the sum overflow, and every listing and page failed until one was dropped (#342).
|
||||
|
||||
## [1.3.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- A feed's items written in Markdown show formatted on its page, from a Markdown description or RSS's `<source:markdown>` (#284).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A listed feed whose address serves something larger than 50 MB is refused as too large, instead of read whole into memory at every check (#295).
|
||||
|
||||
### Security
|
||||
|
||||
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one (#281).
|
||||
|
||||
## [1.2.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
@@ -58,7 +85,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
||||
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...main
|
||||
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.3.0...directory-v1.4.0
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...directory-v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
|
||||
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "ipx-directory"
|
||||
version = "1.2.0"
|
||||
version = "1.4.0"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "ipx-directory"
|
||||
|
||||
@@ -96,8 +96,11 @@ async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<(
|
||||
}
|
||||
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
||||
tracing::info!(
|
||||
shows = ids.len(), accepted = result.accepted, refused_private = result.refused_private,
|
||||
refused_blocked = result.refused_blocked, refused_invalid = result.refused_invalid,
|
||||
shows = ids.len(),
|
||||
accepted = result.accepted,
|
||||
refused_private = result.refused_private,
|
||||
refused_blocked = result.refused_blocked,
|
||||
refused_invalid = result.refused_invalid,
|
||||
"Apple Podcasts synced"
|
||||
);
|
||||
// Fetched now, not when the refresher next looks.
|
||||
@@ -128,7 +131,11 @@ struct Genre {
|
||||
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
||||
g.0.get("26")
|
||||
.map(|podcasts| {
|
||||
podcasts.subgenres.values().map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect())).collect()
|
||||
podcasts
|
||||
.subgenres
|
||||
.values()
|
||||
.map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -253,7 +260,8 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
||||
let one: Chart = serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
||||
let one: Chart =
|
||||
serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
||||
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
|
||||
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
||||
assert!(chart_ids(&empty).is_empty());
|
||||
|
||||
@@ -23,6 +23,10 @@ pub struct Config {
|
||||
/// left out, does neither.
|
||||
#[serde(default)]
|
||||
pub apple_country: String,
|
||||
/// Internal addresses and networks the refresher may fetch feeds from all the same, as ipx's
|
||||
/// `fetch_internal` (#329). Empty, the default, fetches nothing off the public internet.
|
||||
#[serde(default)]
|
||||
pub fetch_internal: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tokens: Vec<TokenCfg>,
|
||||
}
|
||||
@@ -162,7 +166,10 @@ mod tests {
|
||||
let cfg = Config::parse(GOOD).unwrap();
|
||||
assert_eq!(cfg.bind, "0.0.0.0:8100");
|
||||
let t = cfg.tokens().unwrap();
|
||||
assert_eq!((t[0].name.as_str(), t[0].access, t[0].sha256.as_str()), ("tower", Access::Rwa, "a".repeat(64).as_str()));
|
||||
assert_eq!(
|
||||
(t[0].name.as_str(), t[0].access, t[0].sha256.as_str()),
|
||||
("tower", Access::Rwa, "a".repeat(64).as_str())
|
||||
);
|
||||
assert_eq!(t[1].access, Access::Ro);
|
||||
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
|
||||
}
|
||||
@@ -171,7 +178,9 @@ mod tests {
|
||||
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
|
||||
let h = "a".repeat(64);
|
||||
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err());
|
||||
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err());
|
||||
assert!(
|
||||
Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err()
|
||||
);
|
||||
assert!(Config::parse(&GOOD.replace("\"friend\"", "\"tower\"")).is_err());
|
||||
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), "xyz")).unwrap().tokens().is_err());
|
||||
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());
|
||||
|
||||
@@ -127,20 +127,54 @@ pub struct Directory {
|
||||
client: reqwest::Client,
|
||||
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
|
||||
wake: tokio::sync::Notify,
|
||||
/// The public pages' copy of the list, and when it was read.
|
||||
pages_list: tokio::sync::Mutex<Option<(Instant, Arc<Vec<Listing>>)>>,
|
||||
}
|
||||
|
||||
/// How long the public pages show the same copy of the list.
|
||||
const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
|
||||
|
||||
pub type Shared = Arc<Directory>;
|
||||
|
||||
impl Directory {
|
||||
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> {
|
||||
pub fn new(
|
||||
store: Store,
|
||||
tokens: Vec<Token>,
|
||||
fetch_internal: ipx_feed::guard::Allowed,
|
||||
) -> Result<Shared, reqwest::Error> {
|
||||
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.read_timeout(ipx_feed::FEED_SILENCE)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
// The feeds are named by whoever holds a write token, and redirect where their
|
||||
// publishers like: nothing on this network is fetched unless listed (#329).
|
||||
.connector_layer(ipx_feed::guard::Guard::new(fetch_internal))
|
||||
.build()?;
|
||||
Ok(Arc::new(Self { store, tokens: RwLock::new(tokens), client, wake: tokio::sync::Notify::new() }))
|
||||
Ok(Arc::new(Self {
|
||||
store,
|
||||
tokens: RwLock::new(tokens),
|
||||
client,
|
||||
wake: tokio::sync::Notify::new(),
|
||||
pages_list: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The whole list for the public pages, read from the database at most once a minute. Anyone
|
||||
/// may ask for a page, and each asked the database for the whole catalogue on the pool the
|
||||
/// refresher and every server's /v1 calls share; a query string of its own got past any
|
||||
/// cache in front (#341). The lock is held through the read, so a burst waits on one query.
|
||||
pub async fn pages_list(&self) -> anyhow::Result<Arc<Vec<Listing>>> {
|
||||
let mut held = self.pages_list.lock().await;
|
||||
if let Some((at, list)) = held.as_ref()
|
||||
&& at.elapsed() < PAGES_LIST_FOR
|
||||
{
|
||||
return Ok(list.clone());
|
||||
}
|
||||
let list = Arc::new(self.store.list().await?);
|
||||
*held = Some((Instant::now(), list.clone()));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
|
||||
@@ -153,16 +187,16 @@ impl Directory {
|
||||
fn caller(&self, presented: &str) -> Option<Me> {
|
||||
let h = config::hash(presented);
|
||||
// Every token compared, whether or not one matched, so timing says nothing either way.
|
||||
self.tokens
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.fold(None, |found, t| if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found })
|
||||
self.tokens.read().unwrap().iter().fold(None, |found, t| {
|
||||
if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found }
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches one feed and stores what it found.
|
||||
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
|
||||
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref()).await {
|
||||
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok((ipx_feed::Fetched::NotModified, _)) => Refreshed::NotModified,
|
||||
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
|
||||
if ipx_feed::is_opml(&bytes) {
|
||||
@@ -242,10 +276,14 @@ pub async fn reloader(dir: Shared, path: std::path::PathBuf) {
|
||||
let n = tokens.len();
|
||||
match dir.set_tokens(tokens).await {
|
||||
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped"),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were"),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,7 +380,8 @@ async fn authenticate(State(dir): State<Shared>, mut req: Request, next: Next) -
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty());
|
||||
let Some(me) = presented.and_then(|t| dir.caller(t)) else {
|
||||
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into()).into_response();
|
||||
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into())
|
||||
.into_response();
|
||||
};
|
||||
let who = Who(me.name.clone());
|
||||
req.extensions_mut().insert(me);
|
||||
@@ -387,7 +426,11 @@ async fn popular(State(dir): State<Shared>, Query(q): Query<Limit>) -> Result<Js
|
||||
}
|
||||
|
||||
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
||||
dir.store.feed(&id).await?.map(Json).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
|
||||
dir.store
|
||||
.feed(&id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
|
||||
}
|
||||
|
||||
async fn snapshot(
|
||||
@@ -408,7 +451,11 @@ async fn snapshot(
|
||||
Ok(Json(r))
|
||||
}
|
||||
|
||||
async fn remove(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
|
||||
async fn remove(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
dir.store.block(&id).await?;
|
||||
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
|
||||
@@ -420,7 +467,11 @@ async fn blocked(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Res
|
||||
Ok(Json(dir.store.blocked().await?))
|
||||
}
|
||||
|
||||
async fn unblock(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
|
||||
async fn unblock(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
if !dir.store.unblock(&id).await? {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
|
||||
@@ -434,16 +485,28 @@ async fn instances(State(dir): State<Shared>, Extension(me): Extension<Me>) -> R
|
||||
Ok(Json(dir.store.instances().await?))
|
||||
}
|
||||
|
||||
async fn drop_instance(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(name): Path<String>) -> Result<StatusCode, ApiError> {
|
||||
async fn drop_instance(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
let n = dir.store.drop_instance(&name).await?;
|
||||
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn refresh(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
||||
async fn refresh(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<FeedPage>, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
let due = dir.store.due_one(&id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
|
||||
let due = dir
|
||||
.store
|
||||
.due_one(&id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
|
||||
dir.refresh(&due).await?;
|
||||
feed(State(dir), Path(id)).await
|
||||
}
|
||||
@@ -463,11 +526,17 @@ mod tests {
|
||||
sha256: config::hash(&format!("secret-{n}")),
|
||||
})
|
||||
.collect();
|
||||
let dir = Directory::new(Store::scratch().await, tokens).unwrap();
|
||||
let dir = Directory::new(Store::scratch().await, tokens, Default::default()).unwrap();
|
||||
(router(dir.clone()), dir)
|
||||
}
|
||||
|
||||
async fn call(app: &Router, method: &str, path: &str, token: Option<&str>, body: Option<serde_json::Value>) -> (u16, serde_json::Value) {
|
||||
async fn call(
|
||||
app: &Router,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> (u16, serde_json::Value) {
|
||||
let mut req = axum::http::Request::builder().method(method).uri(path);
|
||||
if let Some(t) = token {
|
||||
req = req.header("authorization", format!("Bearer secret-{t}"));
|
||||
@@ -501,20 +570,38 @@ mod tests {
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
|
||||
let got: serde_json::Value =
|
||||
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
|
||||
assert_eq!(got["accepted"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_refresher_fetches_nothing_on_this_network() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
|
||||
let (_, dir) = app().await;
|
||||
let err = dir.client.get(format!("http://{addr}/feed.xml")).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_public_pages_read_the_list_once_a_minute() {
|
||||
let (_, dir) = app().await;
|
||||
let first = dir.pages_list().await.unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()), "a second page asks the database nothing");
|
||||
// Once the copy is a minute old, the next page reads the list again.
|
||||
dir.pages_list.lock().await.as_mut().unwrap().0 -= PAGES_LIST_FOR;
|
||||
assert!(!Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn answers_come_compressed_as_the_caller_asks() {
|
||||
let (app, _) = app().await;
|
||||
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
|
||||
// A page, not an empty feed list: under 32 bytes is sent as it is.
|
||||
let req = axum::http::Request::builder()
|
||||
.uri("/")
|
||||
.header("accept-encoding", asked)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let req =
|
||||
axum::http::Request::builder().uri("/").header("accept-encoding", asked).body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
||||
@@ -583,7 +670,11 @@ mod tests {
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
|
||||
assert_eq!(call(&app, "GET", "/v1/popular?limit=5", Some("ro"), None).await.1[0]["id"], id.as_str());
|
||||
|
||||
for (m, p) in [("DELETE", format!("/v1/feeds/{id}")), ("GET", "/v1/blocked".into()), ("DELETE", "/v1/instances/rw-token".into())] {
|
||||
for (m, p) in [
|
||||
("DELETE", format!("/v1/feeds/{id}")),
|
||||
("GET", "/v1/blocked".into()),
|
||||
("DELETE", "/v1/instances/rw-token".into()),
|
||||
] {
|
||||
assert_eq!(call(&app, m, &p, Some("rw"), None).await.0, 403, "{m} {p}");
|
||||
}
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), Some("rwa"), None).await.0, 204);
|
||||
@@ -598,7 +689,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_revoked_token_loses_its_access_and_its_counts() {
|
||||
let dir = Directory::new(Store::scratch().await, vec![]).unwrap();
|
||||
let dir = Directory::new(Store::scratch().await, vec![], Default::default()).unwrap();
|
||||
let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) };
|
||||
dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
|
||||
let app = router(dir.clone());
|
||||
|
||||
@@ -74,15 +74,24 @@ async fn main() -> Result<()> {
|
||||
async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
// One JSON object a line for Loki when DIRECTORY_LOG_FORMAT=json, as ipx does; text otherwise.
|
||||
let json = std::env::var("DIRECTORY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
let filter = || tracing_subscriber::EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
|
||||
let filter =
|
||||
|| tracing_subscriber::EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
|
||||
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's (#213).
|
||||
let otel = ipx_directory::otel::provider("ipx-directory")?;
|
||||
tracing_subscriber::registry()
|
||||
.with((!json).then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter())))
|
||||
.with(json.then(|| tracing_subscriber::fmt::layer().json().flatten_event(true).with_writer(std::io::stderr).with_filter(filter())))
|
||||
.with(json.then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(filter())
|
||||
}))
|
||||
.with(otel.as_ref().map(|p| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx-directory"))
|
||||
.with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
|
||||
@@ -93,13 +102,16 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
|
||||
let store = Store::open(&url, true).await.context("opening the database")?;
|
||||
let dir = Directory::new(store, vec![])?;
|
||||
let dir = Directory::new(store, vec![], ipx_feed::guard::Allowed::parse(&cfg.fetch_internal)?)?;
|
||||
let dropped = dir.set_tokens(tokens).await?;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
|
||||
if !cfg.metrics_bind.is_empty() {
|
||||
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
||||
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind)
|
||||
.await
|
||||
.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
||||
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
|
||||
tokio::spawn(ipx_directory::metrics::serve(metrics));
|
||||
}
|
||||
@@ -112,7 +124,8 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||
axum::serve(listener, ipx_directory::router(dir))
|
||||
.with_graceful_shutdown(async {
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
|
||||
let mut term =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = term.recv() => {}
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
|
||||
@@ -14,7 +14,8 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
||||
pub fn process() -> (Option<f64>, Option<f64>) {
|
||||
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
|
||||
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
let kb =
|
||||
s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
Some(kb * 1024.0)
|
||||
});
|
||||
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
||||
@@ -35,10 +36,34 @@ fn render(started: std::time::Instant) -> String {
|
||||
if let Some(v) = fds {
|
||||
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
|
||||
}
|
||||
gauge("directory_feed_fetches_total", "counter", "Feeds fetched, answered 304 included.", "", ipx_feed::FETCHES.load(Relaxed) as f64);
|
||||
gauge("directory_feed_bytes_total", "counter", "Bytes of feed bodies fetched.", "", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64);
|
||||
gauge("directory_uptime_seconds", "gauge", "Seconds since the directory started.", "", started.elapsed().as_secs() as f64);
|
||||
gauge("directory_build_info", "gauge", "The version running, as a label.", &format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")), 1.0);
|
||||
gauge(
|
||||
"directory_feed_fetches_total",
|
||||
"counter",
|
||||
"Feeds fetched, answered 304 included.",
|
||||
"",
|
||||
ipx_feed::FETCHES.load(Relaxed) as f64,
|
||||
);
|
||||
gauge(
|
||||
"directory_feed_bytes_total",
|
||||
"counter",
|
||||
"Bytes of feed bodies fetched.",
|
||||
"",
|
||||
ipx_feed::FETCHED_BYTES.load(Relaxed) as f64,
|
||||
);
|
||||
gauge(
|
||||
"directory_uptime_seconds",
|
||||
"gauge",
|
||||
"Seconds since the directory started.",
|
||||
"",
|
||||
started.elapsed().as_secs() as f64,
|
||||
);
|
||||
gauge(
|
||||
"directory_build_info",
|
||||
"gauge",
|
||||
"The version running, as a label.",
|
||||
&format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")),
|
||||
1.0,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -46,14 +71,15 @@ fn render(started: std::time::Instant) -> String {
|
||||
/// Access in front, and these are for the LAN.
|
||||
pub async fn serve(listener: tokio::net::TcpListener) {
|
||||
let started = std::time::Instant::now();
|
||||
let app = axum::Router::new().route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || async move {
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || async move {
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!(error = %e, "metrics stopped");
|
||||
}
|
||||
@@ -64,6 +90,9 @@ mod tests {
|
||||
#[test]
|
||||
fn metrics_say_memory_and_feeds() {
|
||||
let text = super::render(std::time::Instant::now());
|
||||
assert!(text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "), "{text}");
|
||||
assert!(
|
||||
text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +70,21 @@ mod tests {
|
||||
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assert!(cx.span().span_context().is_remote());
|
||||
// Anything else from the browser starts a trace of its own.
|
||||
for bad in ["", "00-xyz-00f067aa0ba902b7-01", "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
|
||||
for bad in [
|
||||
"",
|
||||
"00-xyz-00f067aa0ba902b7-01",
|
||||
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01",
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
] {
|
||||
assert!(with(bad).is_none(), "{bad}");
|
||||
}
|
||||
assert!(remote_parent(&axum::http::HeaderMap::new(), None).is_none());
|
||||
// From the query, where a beacon or Safari's player cannot send a header (#192).
|
||||
let q = remote_parent(&axum::http::HeaderMap::new(), Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"));
|
||||
let q = remote_parent(
|
||||
&axum::http::HeaderMap::new(),
|
||||
Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
|
||||
);
|
||||
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
|
||||
use crate::{Listing, Shared};
|
||||
|
||||
@@ -58,15 +58,17 @@ impl Filter {
|
||||
}
|
||||
|
||||
async fn home(State(dir): State<Shared>) -> Response {
|
||||
let all = match dir.store.list().await {
|
||||
let all = match dir.pages_list().await {
|
||||
Ok(a) => a,
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
let mut top = all.clone();
|
||||
top.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase())));
|
||||
let mut top = (*all).clone();
|
||||
top.sort_by(|a, b| {
|
||||
b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))
|
||||
});
|
||||
top.truncate(10);
|
||||
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
|
||||
for l in &all {
|
||||
for l in all.iter() {
|
||||
cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
|
||||
}
|
||||
let mut b = String::new();
|
||||
@@ -75,12 +77,25 @@ async fn home(State(dir): State<Shared>) -> Response {
|
||||
} else {
|
||||
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
|
||||
for (i, l) in top.iter().enumerate() {
|
||||
let _ = write!(b, "<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
|
||||
esc(&l.id), i + 1, art(l, "art sm"), esc(name(l)), subscribers(l.subscribers));
|
||||
let _ = write!(
|
||||
b,
|
||||
"<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
|
||||
esc(&l.id),
|
||||
i + 1,
|
||||
art(l, "art sm"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers)
|
||||
);
|
||||
}
|
||||
b.push_str("</ol><h2>Categories</h2><div class=cats>");
|
||||
for (c, ls) in &cats {
|
||||
let _ = write!(b, "<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>", seg(c), esc(c), ls.len());
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>",
|
||||
seg(c),
|
||||
esc(c),
|
||||
ls.len()
|
||||
);
|
||||
}
|
||||
let _ = write!(b, "</div><p><a class=more href=\"/all\">All {} feeds, A to Z</a></p>", all.len());
|
||||
}
|
||||
@@ -108,17 +123,18 @@ async fn category(State(dir): State<Shared>, Path(c): Path<String>, Query(f): Qu
|
||||
|
||||
/// A page of tiles: those `pick` takes, of the kind and subcategory asked for, A to Z.
|
||||
async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn(&Listing) -> bool) -> Response {
|
||||
let mut ls: Vec<Listing> = match dir.store.list().await {
|
||||
Ok(a) => a.into_iter().filter(|l| pick(l)).collect(),
|
||||
let mut ls: Vec<Listing> = match dir.pages_list().await {
|
||||
Ok(a) => a.iter().filter(|l| pick(l)).cloned().collect(),
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
if ls.is_empty() && here.starts_with("/category/") {
|
||||
return not_found("That category has nothing in it.");
|
||||
}
|
||||
let subs: BTreeMap<String, usize> = ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
|
||||
*m.entry(s).or_default() += 1;
|
||||
m
|
||||
});
|
||||
let subs: BTreeMap<String, usize> =
|
||||
ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
|
||||
*m.entry(s).or_default() += 1;
|
||||
m
|
||||
});
|
||||
ls.retain(|l| f.keep(l) && f.sub.as_ref().is_none_or(|s| l.subcategory.as_ref() == Some(s)));
|
||||
ls.sort_by_key(|l| name(l).to_lowercase());
|
||||
|
||||
@@ -138,18 +154,38 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
|
||||
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
|
||||
};
|
||||
let mut b = String::new();
|
||||
let _ = write!(b, "<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>", esc(title));
|
||||
let _ = write!(
|
||||
b,
|
||||
"<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>",
|
||||
esc(title)
|
||||
);
|
||||
for (k, label) in [(None, "All"), (Some("podcasts"), "Podcasts"), (Some("blogs"), "Blogs")] {
|
||||
let on = f.kind.as_deref() == k;
|
||||
let _ = write!(b, "<a href=\"{}\"{}>{label}</a>", esc(&link(k, f.sub.as_deref())), if on { " aria-current=page" } else { "" });
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>{label}</a>",
|
||||
esc(&link(k, f.sub.as_deref())),
|
||||
if on { " aria-current=page" } else { "" }
|
||||
);
|
||||
}
|
||||
b.push_str("</nav>");
|
||||
if !subs.is_empty() {
|
||||
b.push_str("<nav class=pills aria-label=Subcategory>");
|
||||
let _ = write!(b, "<a href=\"{}\"{}>All</a>", esc(&link(f.kind.as_deref(), None)), if f.sub.is_none() { " aria-current=page" } else { "" });
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>All</a>",
|
||||
esc(&link(f.kind.as_deref(), None)),
|
||||
if f.sub.is_none() { " aria-current=page" } else { "" }
|
||||
);
|
||||
for (s, n) in &subs {
|
||||
let on = f.sub.as_deref() == Some(s.as_str());
|
||||
let _ = write!(b, "<a href=\"{}\"{}>{} <small>{n}</small></a>", esc(&link(f.kind.as_deref(), Some(s))), if on { " aria-current=page" } else { "" }, esc(s));
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>{} <small>{n}</small></a>",
|
||||
esc(&link(f.kind.as_deref(), Some(s))),
|
||||
if on { " aria-current=page" } else { "" },
|
||||
esc(s)
|
||||
);
|
||||
}
|
||||
b.push_str("</nav>");
|
||||
}
|
||||
@@ -158,7 +194,14 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
|
||||
} else {
|
||||
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
|
||||
for l in &ls {
|
||||
let _ = write!(b, "<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>", esc(&l.id), art(l, "art"), esc(name(l)), subscribers(l.subscribers));
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>",
|
||||
esc(&l.id),
|
||||
art(l, "art"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers)
|
||||
);
|
||||
}
|
||||
b.push_str("</div>");
|
||||
}
|
||||
@@ -177,11 +220,21 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
||||
if let Some(c) = &l.category {
|
||||
let _ = write!(b, " <span aria-hidden=true>/</span> <a href=\"/category/{}\">{}</a>", seg(c), esc(c));
|
||||
}
|
||||
let _ = write!(b, "</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>", art(l, "art lg"), esc(name(l)),
|
||||
subscribers(l.subscribers), l.subcategory.as_deref().map(|s| format!(" · {}", esc(s))).unwrap_or_default());
|
||||
let _ = write!(b, "<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
|
||||
let _ = write!(
|
||||
b,
|
||||
"</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>",
|
||||
art(l, "art lg"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers),
|
||||
l.subcategory.as_deref().map(|s| format!(" · {}", esc(s))).unwrap_or_default()
|
||||
);
|
||||
let _ = write!(
|
||||
b,
|
||||
"<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
|
||||
<button type=button data-copy=\"{}\">Copy</button></span><small>Paste it into any podcast app or feed reader to subscribe.</small></div></div></div>",
|
||||
esc(&l.url), esc(&l.url));
|
||||
esc(&l.url),
|
||||
esc(&l.url)
|
||||
);
|
||||
if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) {
|
||||
let _ = write!(b, "<p class=about>{}</p>", esc(&d));
|
||||
}
|
||||
@@ -199,17 +252,26 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
||||
None => esc(&title),
|
||||
};
|
||||
let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect();
|
||||
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)].into_iter().flatten().collect::<Vec<_>>().join(" · ");
|
||||
let _ = write!(b, "<article><b>{head}</b>{}{}</article>",
|
||||
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let _ = write!(
|
||||
b,
|
||||
"<article><b>{head}</b>{}{}</article>",
|
||||
if blurb.is_empty() { String::new() } else { format!("<p>{}</p>", esc(&blurb)) },
|
||||
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") });
|
||||
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") }
|
||||
);
|
||||
}
|
||||
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
|
||||
}
|
||||
|
||||
fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
||||
let full = if title == "iPX Directory" { title.to_owned() } else { format!("{title} | iPX Directory") };
|
||||
let desc = description.filter(|d| !d.is_empty()).map(|d| d.chars().take(200).collect::<String>())
|
||||
let desc = description
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(|d| d.chars().take(200).collect::<String>())
|
||||
.unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into());
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
|
||||
@@ -234,14 +296,22 @@ fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
||||
}
|
||||
|
||||
fn not_found(why: &str) -> Response {
|
||||
let mut r = page("Not found", None, &format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)));
|
||||
let mut r = page(
|
||||
"Not found",
|
||||
None,
|
||||
&format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)),
|
||||
);
|
||||
*r.status_mut() = StatusCode::NOT_FOUND;
|
||||
r
|
||||
}
|
||||
|
||||
fn failed(e: anyhow::Error) -> Response {
|
||||
tracing::error!(error = %format!("{e:#}"), "a directory page failed");
|
||||
let mut r = page("Unavailable", None, "<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>");
|
||||
let mut r = page(
|
||||
"Unavailable",
|
||||
None,
|
||||
"<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>",
|
||||
);
|
||||
*r.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
|
||||
r
|
||||
}
|
||||
@@ -253,8 +323,12 @@ fn redirect(to: &'static str) -> Response {
|
||||
/// A cover from its publisher, over the feed's initials, which show where it has none or it fails.
|
||||
fn art(l: &Listing, class: &str) -> String {
|
||||
// The first letter of each of the first two words that have one: `<antirez>` is A, not nothing.
|
||||
let initials: String = name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
|
||||
let img = l.image.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://"))
|
||||
let initials: String =
|
||||
name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
|
||||
let img = l
|
||||
.image
|
||||
.as_deref()
|
||||
.filter(|u| u.starts_with("https://") || u.starts_with("http://"))
|
||||
.map(|u| format!("<img src=\"{}\" alt=\"\" loading=lazy referrerpolicy=no-referrer>", esc(u)))
|
||||
.unwrap_or_default();
|
||||
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
|
||||
@@ -301,7 +375,13 @@ fn clock(secs: i64) -> String {
|
||||
fn text(html: &str) -> String {
|
||||
// A space where each tag was, or two paragraphs run together: "drives.For over".
|
||||
let stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
|
||||
let decoded = stripped.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace(" ", " ").replace("&", "&");
|
||||
let decoded = stripped
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&");
|
||||
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ pub const ITEMS: usize = 20;
|
||||
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
|
||||
const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
||||
|
||||
/// The most subscribers one instance may say a feed has.
|
||||
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
|
||||
|
||||
pub struct Store {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
@@ -28,11 +31,7 @@ pub struct Due {
|
||||
/// What a fetch found, to be stored.
|
||||
pub enum Refreshed {
|
||||
NotModified,
|
||||
Feed {
|
||||
parsed: ipx_feed::ParsedFeed,
|
||||
etag: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
},
|
||||
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
@@ -122,7 +121,12 @@ impl Store {
|
||||
let schema = format!("ipxd_{pid}_{n}");
|
||||
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
Self::open(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false).await.unwrap()
|
||||
Self::open(
|
||||
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `keep_open` for the server, which keeps its whole pool connected.
|
||||
@@ -195,8 +199,12 @@ impl Store {
|
||||
/// blocked feed is left out and counted; the rest are added to the feeds, new ones to be
|
||||
/// fetched by the refresher. Feeds no instance sends any more go.
|
||||
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
|
||||
let blocked: std::collections::HashSet<String> =
|
||||
self.rows("SELECT id FROM blocked", vec![]).await?.iter().map(|r| r.try_get("", "id")).collect::<Result<_, _>>()?;
|
||||
let blocked: std::collections::HashSet<String> = self
|
||||
.rows("SELECT id FROM blocked", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "id"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut result = SnapshotResult::default();
|
||||
let mut keep = std::collections::BTreeMap::new();
|
||||
for e in entries {
|
||||
@@ -216,7 +224,9 @@ impl Store {
|
||||
continue;
|
||||
}
|
||||
// The same feed twice in one snapshot counts once, at its larger number.
|
||||
let n = e.subscribers.max(0);
|
||||
// Bounded above as well: two instances' i64::MAX summed past BIGINT, and every listing
|
||||
// and page failed until the instance was dropped (#342). No server has a million.
|
||||
let n = e.subscribers.clamp(0, MAX_SUBSCRIBERS);
|
||||
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
|
||||
let slot = keep.entry(id).or_insert((url, n, path.clone()));
|
||||
slot.1 = slot.1.max(n);
|
||||
@@ -372,7 +382,13 @@ impl Store {
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| Ok(Instance { name: r.try_get("", "instance")?, feeds: r.try_get("", "feeds")?, subscribers: r.try_get("", "subscribers")? }))
|
||||
.map(|r| {
|
||||
Ok(Instance {
|
||||
name: r.try_get("", "instance")?,
|
||||
feeds: r.try_get("", "feeds")?,
|
||||
subscribers: r.try_get("", "subscribers")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -380,7 +396,14 @@ impl Store {
|
||||
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| Ok(Blocked { id: r.try_get("", "id")?, url: r.try_get("", "url")?, title: r.try_get("", "title")?, at: r.try_get("", "at")? }))
|
||||
.map(|r| {
|
||||
Ok(Blocked {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
title: r.try_get("", "title")?,
|
||||
at: r.try_get("", "at")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -406,8 +429,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||
Ok(self
|
||||
.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||
.await?
|
||||
.first()
|
||||
.map(|r| -> Result<Due> {
|
||||
@@ -418,14 +440,18 @@ impl Store {
|
||||
last_modified: r.try_get("", "last_modified")?,
|
||||
})
|
||||
})
|
||||
.transpose()?)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
||||
let t = now();
|
||||
match got {
|
||||
Refreshed::NotModified => {
|
||||
self.exec("UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2", vec![t.into(), id.into()]).await?;
|
||||
self.exec(
|
||||
"UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2",
|
||||
vec![t.into(), id.into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Refreshed::Failed(err) => {
|
||||
self.exec(
|
||||
@@ -436,16 +462,25 @@ impl Store {
|
||||
}
|
||||
Refreshed::Feed { parsed, etag, last_modified } => {
|
||||
let path = parsed.category.as_deref().map(ipx_feed::category_path);
|
||||
let podcast = parsed.entries.iter().flat_map(|e| &e.enclosures).any(|e| {
|
||||
e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/"))
|
||||
});
|
||||
let podcast = parsed
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|e| &e.enclosures)
|
||||
.any(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/")));
|
||||
let mut entries = parsed.entries;
|
||||
entries.sort_by_key(|e| std::cmp::Reverse(e.published));
|
||||
// What an item is, never its files: their addresses are where a key would be.
|
||||
let items: Vec<Item> = entries
|
||||
.into_iter()
|
||||
.take(ITEMS)
|
||||
.map(|e| Item { guid: e.guid, title: e.title, link: e.link, published: e.published, duration: e.duration, description: e.description })
|
||||
.map(|e| Item {
|
||||
guid: e.guid,
|
||||
title: e.title,
|
||||
link: e.link,
|
||||
published: e.published,
|
||||
duration: e.duration,
|
||||
description: e.description,
|
||||
})
|
||||
.collect();
|
||||
self.exec(
|
||||
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
|
||||
@@ -476,8 +511,8 @@ impl Store {
|
||||
/// A feed's last error as its page says it: explained once it has been failing a day, or else
|
||||
/// only that the check failed. Never the error itself, which is for the log.
|
||||
fn failure_words(err: &str, failing_for: i64) -> String {
|
||||
Some(err)
|
||||
.filter(|_| failing_for >= FLAG_AFTER_SECS)
|
||||
(failing_for >= FLAG_AFTER_SECS)
|
||||
.then_some(err)
|
||||
.and_then(ipx_feed::explain_failure)
|
||||
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
||||
}
|
||||
@@ -518,6 +553,17 @@ pub(crate) mod tests {
|
||||
assert!(normalise("not a url").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_count_an_instance_sends_breaks_the_listing() {
|
||||
let s = Store::scratch().await;
|
||||
s.snapshot("a", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||
s.snapshot("b", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||
fetch_all(&s).await;
|
||||
assert_eq!(s.list().await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS, "summed, not overflowed (#342)");
|
||||
assert_eq!(s.popular(10).await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS);
|
||||
assert!(s.instances().await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
|
||||
let s = Store::scratch().await;
|
||||
@@ -541,7 +587,10 @@ pub(crate) mod tests {
|
||||
assert_eq!(all[0].subscribers, 3);
|
||||
|
||||
let i = s.instances().await.unwrap();
|
||||
assert_eq!(i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(), [("a", 1, 1), ("b", 1, 2)]);
|
||||
assert_eq!(
|
||||
i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(),
|
||||
[("a", 1, 1), ("b", 1, 2)]
|
||||
);
|
||||
|
||||
// A token taken out of the config file takes its counts.
|
||||
s.keep_instances(&["b".into()]).await.unwrap();
|
||||
@@ -583,7 +632,11 @@ pub(crate) mod tests {
|
||||
#[tokio::test]
|
||||
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
|
||||
let s = Store::scratch().await;
|
||||
let entry = |c: Option<&str>| SnapshotEntry { url: "https://x.test/blog.xml".into(), subscribers: 1, category: c.map(Into::into) };
|
||||
let entry = |c: Option<&str>| SnapshotEntry {
|
||||
url: "https://x.test/blog.xml".into(),
|
||||
subscribers: 1,
|
||||
category: c.map(Into::into),
|
||||
};
|
||||
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
|
||||
let d = s.due(0, 10).await.unwrap().remove(0);
|
||||
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
|
||||
@@ -8,11 +8,15 @@ edition.workspace = true
|
||||
anyhow.workspace = true
|
||||
atom_syndication.workspace = true
|
||||
chrono.workspace = true
|
||||
http.workspace = true
|
||||
hyper-util.workspace = true
|
||||
opml.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
rss.workspace = true
|
||||
serde_json.workspace = true
|
||||
tower.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
|
||||
269
crates/feed/src/guard.rs
Normal file
269
crates/feed/src/guard.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Keeps fetches off internal addresses (#320). A feed, an episode, its artwork and a .torrent are
|
||||
//! all at addresses a publisher or any signed-in person names, and redirects go anywhere, so
|
||||
//! without this the server fetched from its own loopback, its Docker network and the LAN, and
|
||||
//! handed back what answered.
|
||||
//!
|
||||
//! The check is on the connection, after the name is resolved and before a byte of the request
|
||||
//! is sent, so it covers every request, every redirect, an address written as an IP, and a name
|
||||
//! that resolves to an internal address only on the second lookup.
|
||||
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use hyper_util::client::legacy::connect::{Connection, HttpInfo};
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
/// Internal addresses and networks a fetch may reach all the same: config.toml's `fetch_internal`,
|
||||
/// as `192.168.1.20` or `192.168.1.0/24`. Names are not taken: the check sees only the address.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
|
||||
|
||||
impl Allowed {
|
||||
pub fn parse(list: &[String]) -> Result<Self> {
|
||||
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
|
||||
}
|
||||
|
||||
pub fn permits(&self, ip: IpAddr) -> bool {
|
||||
let ip = canonical(ip);
|
||||
!is_internal(ip) || self.0.iter().any(|&(net, bits)| contains(net, bits, ip))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_net(s: &str) -> Result<(IpAddr, u8)> {
|
||||
let bad = || format!("fetch_internal: {s:?} is not an address or a network such as 192.168.1.0/24");
|
||||
let (addr, bits) = s.trim().split_once('/').map_or((s.trim(), None), |(a, b)| (a, Some(b)));
|
||||
let ip = canonical(addr.parse::<IpAddr>().with_context(bad)?);
|
||||
let max = if ip.is_ipv4() { 32 } else { 128 };
|
||||
let bits = match bits {
|
||||
Some(b) => b.parse::<u8>().ok().filter(|n| *n <= max).with_context(bad)?,
|
||||
None => max,
|
||||
};
|
||||
Ok((ip, bits))
|
||||
}
|
||||
|
||||
/// An IPv4 address written as IPv6 (`::ffff:127.0.0.1`) is the IPv4 one.
|
||||
fn canonical(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
|
||||
v4 => v4,
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(net: IpAddr, bits: u8, ip: IpAddr) -> bool {
|
||||
match (net, ip) {
|
||||
(IpAddr::V4(n), IpAddr::V4(i)) => {
|
||||
let mask = u32::MAX.checked_shl(32 - u32::from(bits)).unwrap_or(0);
|
||||
u32::from(n) & mask == u32::from(i) & mask
|
||||
}
|
||||
(IpAddr::V6(n), IpAddr::V6(i)) => {
|
||||
let mask = u128::MAX.checked_shl(128 - u32::from(bits)).unwrap_or(0);
|
||||
u128::from(n) & mask == u128::from(i) & mask
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Not on the public internet: loopback, private, link-local, shared (CGNAT), multicast and
|
||||
/// reserved ranges, and their IPv6 counterparts. `IpAddr::is_global` would say this, but is
|
||||
/// unstable.
|
||||
// shortcut: 6to4 (2002::/16) and Teredo addresses that wrap an internal IPv4 one pass; check the
|
||||
// embedded address if an IPv6 route to them ever exists here.
|
||||
pub fn is_internal(ip: IpAddr) -> bool {
|
||||
match canonical(ip) {
|
||||
IpAddr::V4(a) => {
|
||||
let [o0, o1, o2, _] = a.octets();
|
||||
a.is_private()
|
||||
|| a.is_loopback()
|
||||
|| a.is_link_local()
|
||||
|| a.is_unspecified()
|
||||
|| a.is_broadcast()
|
||||
|| a.is_multicast()
|
||||
|| o0 == 0
|
||||
|| o0 >= 240
|
||||
|| (o0 == 100 && o1 & 0xc0 == 64)
|
||||
|| (o0 == 192 && o1 == 0 && o2 == 0)
|
||||
}
|
||||
IpAddr::V6(a) => {
|
||||
let s = a.segments();
|
||||
a.is_loopback()
|
||||
|| a.is_unspecified()
|
||||
|| a.is_multicast()
|
||||
|| s[0] & 0xfe00 == 0xfc00
|
||||
|| s[0] & 0xffc0 == 0xfe80
|
||||
// NAT64 carries an IPv4 address in its last 32 bits.
|
||||
|| (s[0] == 0x64 && s[1] == 0xff9b && is_internal(IpAddr::V4(Ipv4Addr::from(u128::from(a) as u32))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector layer for `reqwest::ClientBuilder::connector_layer`: a connection to an address
|
||||
/// `Allowed` does not permit fails before the request is sent.
|
||||
#[derive(Clone)]
|
||||
pub struct Guard(Allowed);
|
||||
|
||||
impl Guard {
|
||||
pub fn new(allowed: Allowed) -> Self {
|
||||
Self(allowed)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> tower::Layer<S> for Guard {
|
||||
type Service = Guarded<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Guarded<S> {
|
||||
Guarded { inner, allowed: self.0.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Guarded<S> {
|
||||
inner: S,
|
||||
allowed: Allowed,
|
||||
}
|
||||
|
||||
impl<S, R> tower::Service<R> for Guarded<S>
|
||||
where
|
||||
S: tower::Service<R>,
|
||||
S::Response: Connection,
|
||||
S::Error: Into<BoxError>,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = BoxError;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<S::Response, BoxError>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
|
||||
self.inner.poll_ready(cx).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: R) -> Self::Future {
|
||||
let connecting = self.inner.call(req);
|
||||
let allowed = self.allowed.clone();
|
||||
Box::pin(async move {
|
||||
let conn = connecting.await.map_err(Into::into)?;
|
||||
let mut extras = http::Extensions::new();
|
||||
conn.connected().get_extras(&mut extras);
|
||||
// Refused when the peer is unknown too: a connection this cannot check is one it
|
||||
// cannot vouch for.
|
||||
match extras.get::<HttpInfo>().map(|i| i.remote_addr().ip()) {
|
||||
Some(ip) if allowed.permits(ip) => Ok(conn),
|
||||
Some(ip) => {
|
||||
Err(format!("{ip} is an internal address, not fetched (fetch_internal in config.toml)").into())
|
||||
}
|
||||
None => Err("the connection's address is unknown, so it is not fetched".into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_addresses_are_known() {
|
||||
for a in [
|
||||
"127.0.0.1",
|
||||
"10.1.2.3",
|
||||
"172.16.0.1",
|
||||
"192.168.1.130",
|
||||
"169.254.169.254",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
"::1",
|
||||
"::",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"64:ff9b::a00:1",
|
||||
] {
|
||||
assert!(is_internal(ip(a)), "{a}");
|
||||
}
|
||||
for a in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111", "64:ff9b::808:808", "100.128.0.1"] {
|
||||
assert!(!is_internal(ip(a)), "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_allowlist_lets_through_what_it_names_and_no_more() {
|
||||
let a = Allowed::parse(&["192.168.1.0/24".into(), "127.0.0.1".into(), "fd00::/8".into()]).unwrap();
|
||||
assert!(a.permits(ip("192.168.1.20")));
|
||||
assert!(a.permits(ip("127.0.0.1")));
|
||||
assert!(a.permits(ip("::ffff:127.0.0.1")), "the same address written as IPv6");
|
||||
assert!(a.permits(ip("fd12::1")));
|
||||
assert!(!a.permits(ip("192.168.2.1")));
|
||||
assert!(!a.permits(ip("127.0.0.2")));
|
||||
assert!(a.permits(ip("1.1.1.1")), "public addresses need no listing");
|
||||
assert!(Allowed::default().permits(ip("1.1.1.1")));
|
||||
assert!(!Allowed::default().permits(ip("10.0.0.1")));
|
||||
for bad in ["nas.lan", "10.0.0.0/33", "10.0.0.0/x", ""] {
|
||||
assert!(Allowed::parse(&[bad.into()]).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn answers() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let _ = sock.read(&mut [0u8; 1024]).await;
|
||||
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nsecret").await;
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/")
|
||||
}
|
||||
|
||||
fn client(allowed: &[&str]) -> reqwest::Client {
|
||||
let allowed = Allowed::parse(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
|
||||
reqwest::Client::builder().connector_layer(Guard::new(allowed)).build().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetch_from_loopback_is_refused_unless_listed() {
|
||||
let url = answers().await;
|
||||
let err = client(&[]).get(&url).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
|
||||
let body = client(&["127.0.0.1"]).get(&url).send().await.unwrap().text().await.unwrap();
|
||||
assert_eq!(body, "secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_redirect_to_loopback_is_refused() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let inner = answers().await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let _ = sock.read(&mut [0u8; 1024]).await;
|
||||
let r = format!("HTTP/1.1 302 Found\r\nLocation: {inner}\r\nContent-Length: 0\r\n\r\n");
|
||||
let _ = sock.write_all(r.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
// 127.0.0.2 stands for a public address: listed, it is let through, and the redirect it
|
||||
// sends to 127.0.0.1 is not.
|
||||
let err = client(&["127.0.0.2"]).get(format!("http://{addr}/")).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("127.0.0.1 is an internal address"), "{err:?}");
|
||||
}
|
||||
|
||||
/// Run by hand: `cargo test -p ipx-feed guard -- --ignored`. HTTPS gives the peer's address
|
||||
/// too; were it lost under TLS, every https fetch would be refused.
|
||||
#[tokio::test]
|
||||
#[ignore = "reaches the internet"]
|
||||
async fn https_is_checked_and_let_through() {
|
||||
let r = client(&[]).get("https://example.com/").send().await.unwrap();
|
||||
assert!(r.status().is_success());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
|
||||
//! FeedData.__getFeed / __getEntries.
|
||||
|
||||
pub mod guard;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
||||
@@ -27,6 +29,9 @@ pub struct Entry {
|
||||
pub link: Option<String>,
|
||||
pub published: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
||||
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
||||
pub markdown: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub explicit: bool,
|
||||
/// Episode artwork; falls back to the feed's in the UI.
|
||||
@@ -111,6 +116,23 @@ pub fn redact_text(text: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The most of a feed or page read. The largest feeds here are a few MB; without a limit an
|
||||
/// episode's MP3 given as a feed was read whole into memory, at every check (#295).
|
||||
const MAX_BODY: usize = 50 << 20;
|
||||
|
||||
/// A response's body, up to `MAX_BODY`: past it, an error, and the rest is never read.
|
||||
async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
|
||||
if resp.content_length().is_some_and(|n| n > MAX_BODY as u64) {
|
||||
return Err(anyhow!("larger than {} MB, too large to be a feed", MAX_BODY >> 20));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
while let Some(c) = resp.chunk().await? {
|
||||
out.extend_from_slice(&c);
|
||||
anyhow::ensure!(out.len() <= MAX_BODY, "larger than {} MB, too large to be a feed", MAX_BODY >> 20);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
||||
pub async fn fetch(
|
||||
client: &reqwest::Client,
|
||||
@@ -155,15 +177,23 @@ pub async fn fetch(
|
||||
return Ok((Fetched::NotModified, moved));
|
||||
}
|
||||
if !status.is_success() {
|
||||
// WordPress.com answers some readers' feed requests with a browser check that only
|
||||
// JavaScript passes, for hours at a time and whatever the User-Agent; its API has the
|
||||
// same posts and no check (#317).
|
||||
if status == StatusCode::FORBIDDEN
|
||||
&& let Some(host) = url.host_str().map(str::to_owned)
|
||||
&& read_capped(resp).await.is_ok_and(|b| is_wpcom_challenge(&b))
|
||||
{
|
||||
return Ok((wpcom_posts(client, &host).await?, moved));
|
||||
}
|
||||
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
||||
return Err(anyhow!("HTTP {status}"));
|
||||
}
|
||||
let header = |h: reqwest::header::HeaderName| {
|
||||
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
||||
};
|
||||
let header =
|
||||
|h: reqwest::header::HeaderName| resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
let etag = header(ETAG);
|
||||
let last_modified = header(LAST_MODIFIED);
|
||||
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
||||
let bytes = read_capped(resp).await.context("reading body")?;
|
||||
tracing::Span::current().record("http.response.body.size", bytes.len());
|
||||
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||
@@ -172,6 +202,57 @@ pub async fn fetch(
|
||||
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
|
||||
}
|
||||
|
||||
/// WordPress.com's browser check, in place of the feed it was asked for.
|
||||
fn is_wpcom_challenge(body: &[u8]) -> bool {
|
||||
String::from_utf8_lossy(&body[..body.len().min(4096)]).contains("/__challenge/")
|
||||
}
|
||||
|
||||
/// A WordPress.com site's latest posts, from WordPress.com's API, which `parse` reads as a feed.
|
||||
/// shortcut: the API's default page of ten posts, as many as WordPress puts in a feed; a feed
|
||||
/// set to carry more gets ten here.
|
||||
async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
|
||||
let api = format!("https://public-api.wordpress.com/wp/v2/sites/{host}/posts");
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.timeout(FEED_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP 403 Forbidden, a browser check; connecting to WordPress.com's API in its place")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"HTTP 403 Forbidden, a browser check, and WordPress.com's API in its place answered HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
let bytes = read_capped(resp).await.context("reading WordPress.com's API")?;
|
||||
FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(Fetched::Body { bytes, etag: None, last_modified: None })
|
||||
}
|
||||
|
||||
/// WordPress's posts as its REST API sends them (`wpcom_posts`). The guid is the one the site's
|
||||
/// own feed gives each post, so they are the items already stored. No title for the feed: the
|
||||
/// stored one stays.
|
||||
fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
|
||||
let entries = posts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
Some(Entry {
|
||||
guid: non_empty(p["guid"]["rendered"].as_str()).or_else(|| non_empty(p["link"].as_str()))?,
|
||||
title: title_text(p["title"]["rendered"].as_str()),
|
||||
link: non_empty(p["link"].as_str()),
|
||||
published: p["date_gmt"]
|
||||
.as_str()
|
||||
.and_then(|d| chrono::NaiveDateTime::parse_from_str(d, "%Y-%m-%dT%H:%M:%S").ok())
|
||||
.map(|d| d.and_utc().timestamp()),
|
||||
description: non_empty(p["content"]["rendered"].as_str()),
|
||||
image: non_empty(p["jetpack_featured_media_url"].as_str()),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ParsedFeed { entries, ..Default::default() }
|
||||
}
|
||||
|
||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||
/// it is, and whether there is a new address to switch to.
|
||||
pub struct Failure {
|
||||
@@ -188,7 +269,10 @@ pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
||||
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") {
|
||||
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"
|
||||
@@ -222,14 +306,14 @@ pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
||||
/// nothing extra, so a miss costs a clearer message, not a wrong one.
|
||||
pub fn explain_failure(msg: &str) -> Option<Failure> {
|
||||
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
|
||||
let new_url = rest
|
||||
.strip_prefix("; it links ")
|
||||
.and_then(|r| r.strip_suffix(" as its feed"))
|
||||
.map(str::to_owned);
|
||||
let new_url = rest.strip_prefix("; it links ").and_then(|r| r.strip_suffix(" as its feed")).map(str::to_owned);
|
||||
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
|
||||
}
|
||||
if msg.contains("the site sent ") {
|
||||
return Some(Failure { reason: "The site sent a message instead of the feed; the publisher has to fix it.", new_url: None });
|
||||
return Some(Failure {
|
||||
reason: "The site sent a message instead of the feed; the publisher has to fix it.",
|
||||
new_url: None,
|
||||
});
|
||||
}
|
||||
let low = msg.to_ascii_lowercase();
|
||||
if low.contains("http 404") {
|
||||
@@ -247,7 +331,9 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
|
||||
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
|
||||
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|
||||
if !is_temporary_lookup_failure(msg)
|
||||
&& (low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated"))
|
||||
&& (low.contains("dns error")
|
||||
|| low.contains("failed to lookup address")
|
||||
|| low.contains("no address associated"))
|
||||
{
|
||||
return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None });
|
||||
}
|
||||
@@ -273,11 +359,37 @@ pub fn is_opml(bytes: &[u8]) -> bool {
|
||||
text.contains("<opml")
|
||||
}
|
||||
|
||||
/// How deep an OPML document's elements may nest. Real ones are a folder or two deep.
|
||||
const MAX_OPML_DEPTH: usize = 100;
|
||||
|
||||
/// Parses an OPML document, refusing one nested deeper than `MAX_OPML_DEPTH`. The opml crate's
|
||||
/// reader recurses once per level with no limit, and a few MB of `<outline>` overflowed the
|
||||
/// stack, which aborts the whole daemon, and again on each restart for a subscribed feed (#322).
|
||||
pub fn read_opml(text: &str) -> Result<opml::OPML> {
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_str(text);
|
||||
let mut depth = 0usize;
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(_)) => {
|
||||
depth += 1;
|
||||
if depth > MAX_OPML_DEPTH {
|
||||
anyhow::bail!("that OPML nests more than {MAX_OPML_DEPTH} levels deep");
|
||||
}
|
||||
}
|
||||
Ok(Event::End(_)) => depth = depth.saturating_sub(1),
|
||||
// Malformed XML is the opml crate's to describe.
|
||||
Ok(Event::Eof) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
opml::OPML::from_str(text).map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
/// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
|
||||
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let doc = opml::OPML::from_str(&text)
|
||||
.map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
|
||||
let doc = read_opml(&text).map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
|
||||
let mut out = vec![];
|
||||
collect_outlines(&doc.body.outlines, &mut out);
|
||||
Ok(out)
|
||||
@@ -297,11 +409,8 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
|
||||
/// The <head><title> of an OPML document.
|
||||
pub fn opml_title(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let doc = opml::OPML::from_str(&text).ok()?;
|
||||
doc.head
|
||||
.and_then(|h| h.title)
|
||||
.map(|t| t.trim().to_owned())
|
||||
.filter(|t| !t.is_empty())
|
||||
let doc = read_opml(&text).ok()?;
|
||||
doc.head.and_then(|h| h.title).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// The token and show of a Patreon feed link, or None for any other URL.
|
||||
@@ -343,7 +452,11 @@ pub fn expand_input(input: &str) -> String {
|
||||
/// creator's name, by number, or with no creator at all -- and the token and show are what
|
||||
/// identify it.
|
||||
pub fn same_feed(a: &str, b: &str) -> bool {
|
||||
a == b || patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b))
|
||||
// Parsing both is the slow part, and only Patreon's addresses can differ and still match.
|
||||
a == b
|
||||
|| (a.contains("patreon.com")
|
||||
&& b.contains("patreon.com")
|
||||
&& patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b)))
|
||||
}
|
||||
|
||||
/// A Patreon creator's name and shows, each show as (title, feed URL).
|
||||
@@ -351,10 +464,7 @@ pub fn same_feed(a: &str, b: &str) -> bool {
|
||||
/// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes,
|
||||
/// finding shows stops and the show feeds already found keep working. The documented API
|
||||
/// needs an OAuth client per install and does not list shows.
|
||||
pub async fn patreon_shows(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||
pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
||||
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
||||
let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
|
||||
@@ -366,8 +476,7 @@ pub async fn patreon_shows(
|
||||
let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break };
|
||||
head.extend_from_slice(&chunk);
|
||||
}
|
||||
let campaign = patreon_campaign(&head)
|
||||
.ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
||||
let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
|
||||
|
||||
let api = format!(
|
||||
"https://www.patreon.com/api/campaigns/{campaign}\
|
||||
@@ -390,8 +499,11 @@ fn patreon_campaign(head: &[u8]) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A campaign's name, and its shows as (id, title).
|
||||
type PatreonShows = (Option<String>, Vec<(String, String)>);
|
||||
|
||||
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
|
||||
fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||
fn parse_patreon_shows(json: &[u8]) -> Result<PatreonShows> {
|
||||
let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?;
|
||||
// Missing is not the same as none. Read as no shows, the creator feed would be scanned as
|
||||
// a plain feed, claim every show's files, and leave the shows empty once the list returned.
|
||||
@@ -413,6 +525,11 @@ fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, Stri
|
||||
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
|
||||
#[tracing::instrument(skip_all, fields(bytes = bytes.len()))]
|
||||
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
||||
if bytes.trim_ascii_start().starts_with(b"[")
|
||||
&& let Ok(posts) = serde_json::from_slice::<Vec<serde_json::Value>>(bytes)
|
||||
{
|
||||
return Ok(from_wp_posts(&posts));
|
||||
}
|
||||
match rss::Channel::read_from(bytes) {
|
||||
Ok(ch) => Ok(from_rss(ch, bytes)),
|
||||
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
||||
@@ -490,7 +607,8 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||
let tag_lower = &lower[start..end];
|
||||
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
|
||||
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
|
||||
if is_alternate && is_feed_type
|
||||
if is_alternate
|
||||
&& is_feed_type
|
||||
&& let Some(href) = tag_attr(tag, "href")
|
||||
{
|
||||
return Some(href);
|
||||
@@ -505,15 +623,10 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||
/// 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")?;
|
||||
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")?))
|
||||
anyhow::Ok((base, read_capped(resp).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}"))?;
|
||||
@@ -527,7 +640,8 @@ pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
||||
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 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())
|
||||
@@ -545,7 +659,7 @@ pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
||||
// 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 Ok(page) = read_capped(resp).await
|
||||
&& let Some(href) = page_icon(&page)
|
||||
&& let Ok(url) = base.join(&href)
|
||||
&& is_image(client, url.as_str()).await
|
||||
@@ -556,6 +670,9 @@ pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
||||
is_image(client, ico.as_str()).await.then(|| ico.into())
|
||||
}
|
||||
|
||||
/// How many hosts one scan of a feed asks whether its artwork is on https.
|
||||
const MAX_ART_HOSTS: usize = 8;
|
||||
|
||||
/// Artwork's address on https when its host serves it there, else as it was. The page is https
|
||||
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
|
||||
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
|
||||
@@ -571,6 +688,10 @@ pub async fn prefer_https(
|
||||
let secure = format!("https://{rest}");
|
||||
let ok = match known.get(&host) {
|
||||
Some(ok) => *ok,
|
||||
// Each new host is a probe of up to 20 s, one after another on the scan, and the feed's
|
||||
// author decides how many hosts there are: ports and user names make as many as they like
|
||||
// of one server (#339). Real feeds put their artwork on a few.
|
||||
None if known.len() >= MAX_ART_HOSTS => false,
|
||||
None => {
|
||||
let ok = is_image(client, &secure).await;
|
||||
known.insert(host, ok);
|
||||
@@ -682,9 +803,7 @@ fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
|
||||
let (mut url, mut mime, mut length) = (String::new(), None, None);
|
||||
for attr in e.attributes().flatten() {
|
||||
// Values arrive escaped: a feed URL's "&" is "&" in the document.
|
||||
let val = quick_xml::escape::unescape(&attr.value)
|
||||
.map(|v| v.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let val = quick_xml::escape::unescape(&attr.value).map(|v| v.trim().to_string()).unwrap_or_default();
|
||||
match attr.key.local_name().as_ref() {
|
||||
"url" => url = val,
|
||||
"type" => mime = Some(val).filter(|v| !v.is_empty()),
|
||||
@@ -736,10 +855,7 @@ fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
|
||||
|
||||
fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
||||
let per_item = enclosures_by_item(bytes);
|
||||
let explicit = ch
|
||||
.itunes_ext()
|
||||
.and_then(|it| it.explicit())
|
||||
.is_some_and(is_yes);
|
||||
let explicit = ch.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||
|
||||
let entries = ch
|
||||
.items()
|
||||
@@ -768,10 +884,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
||||
item.title(),
|
||||
)?;
|
||||
|
||||
let entry_explicit = item
|
||||
.itunes_ext()
|
||||
.and_then(|it| it.explicit())
|
||||
.is_some_and(is_yes);
|
||||
let entry_explicit = item.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||
let it = item.itunes_ext();
|
||||
|
||||
Some(Entry {
|
||||
@@ -779,8 +892,11 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
||||
title: title_text(item.title()),
|
||||
link: non_empty(item.link()),
|
||||
published: item.pub_date().and_then(parse_date),
|
||||
// Content wins over description, as __getEntries preferred entry.content.
|
||||
description: body(item.content(), item.description()),
|
||||
// Content wins over description, as __getEntries preferred entry.content. The
|
||||
// source namespace's Markdown copy only when there is neither (#284).
|
||||
description: notes(item.content(), item.description(), source_markdown(item)),
|
||||
markdown: non_empty(source_markdown(item))
|
||||
.filter(|_| non_empty(item.content()).or_else(|| non_empty(item.description())).is_some()),
|
||||
categories: item
|
||||
.categories()
|
||||
.iter()
|
||||
@@ -839,25 +955,18 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
||||
.collect();
|
||||
drop_player_repeats(&mut enclosures);
|
||||
|
||||
let alt = e
|
||||
.links()
|
||||
.iter()
|
||||
.find(|l| l.rel() == "alternate" || l.rel().is_empty())
|
||||
.map(|l| l.href());
|
||||
let alt = e.links().iter().find(|l| l.rel() == "alternate" || l.rel().is_empty()).map(|l| l.href());
|
||||
|
||||
let guid = pick_guid(
|
||||
Some(e.id()),
|
||||
alt,
|
||||
enclosures.first().map(|x| x.url.as_str()),
|
||||
Some(e.title().as_str()),
|
||||
)?;
|
||||
let guid =
|
||||
pick_guid(Some(e.id()), alt, enclosures.first().map(|x| x.url.as_str()), Some(e.title().as_str()))?;
|
||||
|
||||
Some(Entry {
|
||||
guid,
|
||||
title: title_text(Some(e.title().as_str())),
|
||||
link: alt.map(str::to_owned),
|
||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
||||
description: notes(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str()), None),
|
||||
markdown: None,
|
||||
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
||||
explicit: false,
|
||||
image: None,
|
||||
@@ -888,18 +997,8 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
||||
/// The original fell back to hashing the title or description. A guid, permalink or
|
||||
/// enclosure URL is a stable identifier already, so no hashing is needed; an entry with
|
||||
/// none of them has nothing to download and is dropped.
|
||||
fn pick_guid(
|
||||
guid: Option<&str>,
|
||||
link: Option<&str>,
|
||||
enclosure: Option<&str>,
|
||||
title: Option<&str>,
|
||||
) -> Option<String> {
|
||||
[guid, link, enclosure, title]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(str::trim)
|
||||
.find(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
fn pick_guid(guid: Option<&str>, link: Option<&str>, enclosure: Option<&str>, title: Option<&str>) -> Option<String> {
|
||||
[guid, link, enclosure, title].into_iter().flatten().map(str::trim).find(|s| !s.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn is_yes(s: &str) -> bool {
|
||||
@@ -946,9 +1045,7 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at]);
|
||||
rest = &rest[at..];
|
||||
let len = 1 + rest[1..]
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
|
||||
.unwrap_or(rest.len() - 1);
|
||||
let len = 1 + rest[1..].find(|c: char| !(c.is_ascii_alphanumeric() || c == '#')).unwrap_or(rest.len() - 1);
|
||||
let decoded = rest[len..]
|
||||
.starts_with(';')
|
||||
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
|
||||
@@ -968,6 +1065,43 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
||||
non_empty(Some(&out))
|
||||
}
|
||||
|
||||
/// An item's notes as HTML: `body`, with Markdown made HTML, whether the feed wrote its text in
|
||||
/// Markdown or sent only a Markdown copy (#284). The page cleans it as it does any feed's HTML.
|
||||
fn notes(content: Option<&str>, description: Option<&str>, markdown: Option<&str>) -> Option<String> {
|
||||
match body(content, description) {
|
||||
Some(b) if is_markdown(&b) => Some(markdown_html(&b)),
|
||||
Some(b) => Some(b),
|
||||
None => non_empty(markdown).map(|m| markdown_html(&m)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<source:markdown>`, from https://source.scripting.com/: the item's text in Markdown.
|
||||
///
|
||||
/// shortcut: found by the `source` prefix the namespace's own docs and feeds use; a feed binding
|
||||
/// it to another prefix is not read, until one turns up.
|
||||
fn source_markdown(item: &rss::Item) -> Option<&str> {
|
||||
item.extensions().get("source")?.get("markdown")?.first()?.value()
|
||||
}
|
||||
|
||||
/// Text with no HTML tag and some Markdown: a link, a heading, a list, a quote, code or bold.
|
||||
/// Plain text without any is left as it was, since rendering it would change nothing but
|
||||
/// could still read a line starting "1984." as a list.
|
||||
fn is_markdown(s: &str) -> bool {
|
||||
let tag = s
|
||||
.match_indices('<')
|
||||
.any(|(i, _)| s[i + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!'));
|
||||
let block = |l: &str| ["# ", "## ", "### ", "- ", "* ", "> ", "```"].iter().any(|m| l.trim_start().starts_with(m));
|
||||
!tag && (s.contains("](") || s.contains("**") || s.lines().any(block))
|
||||
}
|
||||
|
||||
/// Markdown made HTML, uncleaned: whoever shows it cleans it as any feed's HTML.
|
||||
pub fn markdown_html(md: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH));
|
||||
out
|
||||
}
|
||||
|
||||
/// An item's show notes: its full body when that is whole, else its description.
|
||||
///
|
||||
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
||||
@@ -1014,7 +1148,10 @@ fn text_of(html: &str) -> String {
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
out.push(' ');
|
||||
}
|
||||
_ if !in_tag => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
@@ -1051,14 +1188,9 @@ fn item_image(item: &rss::Item, enclosures: &[Enclosure]) -> Option<String> {
|
||||
non_empty(e.attrs.get("url").map(String::as_str))
|
||||
})
|
||||
};
|
||||
attr("thumbnail", false)
|
||||
.or_else(|| attr("content", true))
|
||||
.or_else(|| {
|
||||
enclosures
|
||||
.iter()
|
||||
.find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/")))
|
||||
.map(|e| e.url.clone())
|
||||
})
|
||||
attr("thumbnail", false).or_else(|| attr("content", true)).or_else(|| {
|
||||
enclosures.iter().find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/"))).map(|e| e.url.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
|
||||
@@ -1099,13 +1231,62 @@ const CATEGORIES: &[(&str, &[&str])] = &[
|
||||
("History", &[]),
|
||||
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
|
||||
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
|
||||
("Leisure", &["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"]),
|
||||
(
|
||||
"Leisure",
|
||||
&["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"],
|
||||
),
|
||||
("Music", &["Music Commentary", "Music History", "Music Interviews"]),
|
||||
("News", &["Business News", "Daily News", "Entertainment News", "News Commentary", "Politics", "Sports News", "Tech News"]),
|
||||
("Religion & Spirituality", &["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"]),
|
||||
("Science", &["Astronomy", "Chemistry", "Earth Sciences", "Life Sciences", "Mathematics", "Natural Sciences", "Nature", "Physics", "Social Sciences"]),
|
||||
(
|
||||
"News",
|
||||
&[
|
||||
"Business News",
|
||||
"Daily News",
|
||||
"Entertainment News",
|
||||
"News Commentary",
|
||||
"Politics",
|
||||
"Sports News",
|
||||
"Tech News",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Religion & Spirituality",
|
||||
&["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"],
|
||||
),
|
||||
(
|
||||
"Science",
|
||||
&[
|
||||
"Astronomy",
|
||||
"Chemistry",
|
||||
"Earth Sciences",
|
||||
"Life Sciences",
|
||||
"Mathematics",
|
||||
"Natural Sciences",
|
||||
"Nature",
|
||||
"Physics",
|
||||
"Social Sciences",
|
||||
],
|
||||
),
|
||||
("Society & Culture", &["Documentary", "Personal Journals", "Philosophy", "Places & Travel", "Relationships"]),
|
||||
("Sports", &["Baseball", "Basketball", "Cricket", "Fantasy Sports", "Football", "Golf", "Hockey", "Rugby", "Running", "Soccer", "Swimming", "Tennis", "Volleyball", "Wilderness", "Wrestling"]),
|
||||
(
|
||||
"Sports",
|
||||
&[
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Cricket",
|
||||
"Fantasy Sports",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Hockey",
|
||||
"Rugby",
|
||||
"Running",
|
||||
"Soccer",
|
||||
"Swimming",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wilderness",
|
||||
"Wrestling",
|
||||
],
|
||||
),
|
||||
("Technology", &[]),
|
||||
("True Crime", &[]),
|
||||
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
||||
@@ -1207,18 +1388,38 @@ pub fn url_looks_private(feed_url: &str) -> bool {
|
||||
|| u.password().is_some()
|
||||
|| u.query_pairs().any(|(k, _)| {
|
||||
let k = k.to_ascii_lowercase();
|
||||
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
|
||||
.iter()
|
||||
.any(|w| k.contains(w))
|
||||
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"].iter().any(|w| k.contains(w))
|
||||
})
|
||||
}
|
||||
|
||||
/// Services whose feeds are always one subscriber's own.
|
||||
const PAID_HOSTS: &[&str] =
|
||||
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||
const PAID_HOSTS: &[&str] = &["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn reads_wordpress_posts_as_a_feed() {
|
||||
// As WordPress.com's API sends them, trimmed (#317).
|
||||
let json = br#"[{"id":90248260,"date_gmt":"2026-10-09T13:34:00",
|
||||
"guid":{"rendered":"https:\/\/pelgranepress.com\/?p=90248260"},
|
||||
"link":"https:\/\/pelgranepress.com\/2026\/10\/09\/whisky\/",
|
||||
"title":{"rendered":"Baby’s First Whisky"},"content":{"rendered":"<p>Notes<\/p>"},
|
||||
"jetpack_featured_media_url":""}]"#;
|
||||
let feed = super::parse(json).expect("WordPress posts parse as a feed");
|
||||
assert_eq!(feed.title, None);
|
||||
let e = &feed.entries[0];
|
||||
assert_eq!(e.guid, "https://pelgranepress.com/?p=90248260");
|
||||
assert_eq!(e.title.as_deref(), Some("Baby\u{2019}s First Whisky"));
|
||||
assert_eq!(e.link.as_deref(), Some("https://pelgranepress.com/2026/10/09/whisky/"));
|
||||
assert_eq!(e.published, Some(1_791_552_840));
|
||||
assert_eq!(e.description.as_deref(), Some("<p>Notes</p>"));
|
||||
assert_eq!(e.image, None);
|
||||
assert!(super::is_wpcom_challenge(
|
||||
b"<!DOCTYPE html><title>Checking your browser...</title><script src=\"/__challenge/probes.js\">"
|
||||
));
|
||||
assert!(!super::is_wpcom_challenge(b"<html>Forbidden</html>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_drops_what_carries_a_credential() {
|
||||
assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x");
|
||||
@@ -1227,7 +1428,9 @@ mod tests {
|
||||
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
||||
// As reqwest words a failure, and with two addresses and none.
|
||||
assert_eq!(
|
||||
super::redact_text("connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"),
|
||||
super::redact_text(
|
||||
"connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"
|
||||
),
|
||||
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
|
||||
);
|
||||
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
|
||||
@@ -1266,10 +1469,18 @@ mod tests {
|
||||
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(),
|
||||
"/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(),
|
||||
"/chain" => {
|
||||
"HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
// An episode's file given as a feed: its size said, its body never sent.
|
||||
"/huge" => "HTTP/1.1 200 OK\r\nContent-Length: 600000000\r\n\r\n".to_owned(),
|
||||
"/loop" => {
|
||||
"HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
|
||||
};
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
@@ -1291,9 +1502,11 @@ mod tests {
|
||||
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
|
||||
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
|
||||
let huge = get("/huge").await.err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||
assert!(huge.contains("too large to be a feed"), "{huge}");
|
||||
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
|
||||
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
||||
let looped = get("/loop").await.err().unwrap().to_string();
|
||||
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
|
||||
}
|
||||
@@ -1327,10 +1540,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(feed.entries[1].explicit, "entry-level itunes:explicit=yes");
|
||||
assert_eq!(
|
||||
feed.entries[2].enclosures[0].mime.as_deref(),
|
||||
Some("application/x-bittorrent")
|
||||
);
|
||||
assert_eq!(feed.entries[2].enclosures[0].mime.as_deref(), Some("application/x-bittorrent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1344,6 +1554,40 @@ mod tests {
|
||||
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_notes_are_made_html_and_html_is_left_alone() {
|
||||
let feed = |items: &str| {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>{items}</channel></rss>"#
|
||||
);
|
||||
parse(xml.as_bytes()).unwrap().entries.into_iter().map(|e| e.description).collect::<Vec<_>>()
|
||||
};
|
||||
let got = feed(
|
||||
r#"<item><guid>a</guid><description># Hi
|
||||
|
||||
Some **bold** and [a link](https://x.example/).</description></item>
|
||||
<item><guid>b</guid><source:markdown>Only *Markdown* here.</source:markdown></item>
|
||||
<item><guid>c</guid><description><p>HTML **as sent**</p></description><source:markdown>ignored</source:markdown></item>
|
||||
<item><guid>d</guid><description>1984. Plain text, left as it is.</description></item>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].as_deref(),
|
||||
Some("<h1>Hi</h1>\n<p>Some <strong>bold</strong> and <a href=\"https://x.example/\">a link</a>.</p>\n")
|
||||
);
|
||||
assert_eq!(got[1].as_deref(), Some("<p>Only <em>Markdown</em> here.</p>\n"), "the Markdown copy, alone");
|
||||
assert_eq!(got[2].as_deref(), Some("<p>HTML **as sent**</p>"), "HTML wins, untouched");
|
||||
assert_eq!(got[3].as_deref(), Some("1984. Plain text, left as it is."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_sent_beside_html_is_kept_for_the_page_to_switch_to() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>
|
||||
<item><guid>both</guid><description><p>Hi</p></description><source:markdown>*Hi*</source:markdown></item>
|
||||
<item><guid>alone</guid><source:markdown>*Hi*</source:markdown></item></channel></rss>"#;
|
||||
let got: Vec<_> = parse(xml).unwrap().entries.into_iter().map(|e| e.markdown).collect();
|
||||
assert_eq!(got, [Some("*Hi*".to_owned()), None], "only beside HTML; alone, it is the description (#284)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
||||
@@ -1352,9 +1596,12 @@ mod tests {
|
||||
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
|
||||
<enclosure url="https://x/other.mp3?_=3&key=k" length="1" type="audio/mpeg"/>
|
||||
</item></channel></rss>"#;
|
||||
let urls: Vec<String> =
|
||||
parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
||||
assert_eq!(urls, ["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"], "the repeat goes, a different file stays");
|
||||
let urls: Vec<String> = parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
||||
assert_eq!(
|
||||
urls,
|
||||
["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"],
|
||||
"the repeat goes, a different file stays"
|
||||
);
|
||||
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
|
||||
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
|
||||
}
|
||||
@@ -1381,8 +1628,15 @@ mod tests {
|
||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||
assert_eq!(
|
||||
body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(),
|
||||
Some("<p>Notes</p>"),
|
||||
"a whole body wins"
|
||||
);
|
||||
assert_eq!(
|
||||
body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(),
|
||||
Some("Plain notes, no tags.")
|
||||
);
|
||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||
}
|
||||
@@ -1401,14 +1655,19 @@ mod tests {
|
||||
);
|
||||
// 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 [...]"] {
|
||||
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");
|
||||
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]
|
||||
@@ -1421,10 +1680,7 @@ mod tests {
|
||||
<enclosure url="https://x/a.mp3" length="1" type="audio/mpeg"/></item>
|
||||
</channel></rss>"#;
|
||||
let feed = parse(xml).unwrap();
|
||||
assert!(
|
||||
feed.entries[0].explicit,
|
||||
"the entry says nothing; the feed-level flag must still mark it explicit"
|
||||
);
|
||||
assert!(feed.entries[0].explicit, "the entry says nothing; the feed-level flag must still mark it explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1454,13 +1710,17 @@ mod tests {
|
||||
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
||||
"This address answers with nothing; the site may be gone."
|
||||
);
|
||||
let dns = explain_failure("connecting: dns error: failed to lookup address information: Name or service not known").unwrap();
|
||||
let dns =
|
||||
explain_failure("connecting: dns error: failed to lookup address information: Name or service not known")
|
||||
.unwrap();
|
||||
assert_eq!(dns.reason, "This address no longer resolves; the site may be gone.");
|
||||
// A lookup that failed for the moment is not a site gone (#143).
|
||||
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||
assert!(is_temporary_lookup_failure(blip));
|
||||
assert!(explain_failure(blip).is_none());
|
||||
assert!(!is_temporary_lookup_failure("connecting: dns error: failed to lookup address information: Name or service not known"));
|
||||
assert!(!is_temporary_lookup_failure(
|
||||
"connecting: dns error: failed to lookup address information: Name or service not known"
|
||||
));
|
||||
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
||||
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
||||
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
||||
@@ -1589,6 +1849,32 @@ mod tests {
|
||||
assert_eq!((p1.season, p1.episode), (Some(8), Some(1)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn artwork_on_more_hosts_than_the_limit_is_not_asked_about() {
|
||||
let mut known: std::collections::HashMap<String, bool> =
|
||||
(0..MAX_ART_HOSTS).map(|n| (format!("h{n}:80"), false)).collect();
|
||||
// No server answers here: were it asked, this would wait on a connection, not return.
|
||||
let url = "http://192.0.2.1:9/a.png";
|
||||
assert_eq!(prefer_https(&reqwest::Client::new(), url, &mut known).await, url);
|
||||
assert_eq!(known.len(), MAX_ART_HOSTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_opml_is_refused_before_it_is_parsed() {
|
||||
let nest = |n: usize| {
|
||||
format!(
|
||||
r#"<opml version="2.0"><head><title>T</title></head><body>{}<outline text="f" xmlUrl="https://e.com/f"/>{}</body></opml>"#,
|
||||
r#"<outline text="a">"#.repeat(n),
|
||||
"</outline>".repeat(n)
|
||||
)
|
||||
};
|
||||
assert_eq!(parse_opml(nest(5).as_bytes()).unwrap().len(), 1, "a few folders deep is fine");
|
||||
// Deep enough to overflow a 2 MiB stack in the opml crate's reader without the check.
|
||||
let deep = nest(100_000);
|
||||
assert!(parse_opml(deep.as_bytes()).unwrap_err().to_string().contains("levels deep"));
|
||||
assert_eq!(opml_title(deep.as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opml_is_recognised_and_its_feeds_listed() {
|
||||
let xml = br#"<opml version="2.0"><head><title>My Subscriptions</title></head><body>
|
||||
@@ -1671,10 +1957,7 @@ mod tests {
|
||||
|
||||
let two = &f.entries[0].enclosures;
|
||||
assert_eq!(two.len(), 2, "both enclosures survive");
|
||||
assert_eq!(
|
||||
two[0].url, "https://x/a.mp3?v=1&t=2",
|
||||
"document order, and the escaped ampersand is decoded"
|
||||
);
|
||||
assert_eq!(two[0].url, "https://x/a.mp3?v=1&t=2", "document order, and the escaped ampersand is decoded");
|
||||
assert_eq!(two[0].length, Some(111));
|
||||
assert_eq!(two[1].url, "https://x/b.mp4");
|
||||
assert_eq!(two[1].mime.as_deref(), Some("video/mp4"));
|
||||
@@ -1777,4 +2060,3 @@ mod tests {
|
||||
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.0] - 2026-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Traces show more of where a request's time goes: a page of items' query and its files apart, artwork's cache, check and fetch, the status counts, and each part of changing or leaving a feed (#350).
|
||||
- A page of items finds its files by feed as well as item: it read every file's row in the database, about a quarter of a second a page (#344).
|
||||
- A page of items asks for its count beside the items, and the feed list asks its five questions at once, where each waited for the one before (#346).
|
||||
- A Signal's counts of items and of unread ones are worked out in one pass, not two (#348).
|
||||
- Artwork kept on disk is read and written off the threads that answer requests, and two requests for the same missing artwork fetch it once (#345, #349).
|
||||
- Unread lists and counts start from each person's oldest unread item in each feed, which the daily cleanup works out, instead of reading the whole back catalogue and everything you have read (#347, #352).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A WordPress.com site that answers its feed with a browser check (HTTP 403, "Checking your browser...") is read from WordPress.com's API instead, so Pelgrane Press's feed no longer fails for hours at a time. The items are the ones the feed gave: same ids (#317).
|
||||
|
||||
### Security
|
||||
|
||||
- An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331).
|
||||
- A downloaded file opens in the browser only when it is audio, video, an image other than SVG, a PDF or plain text, and is saved otherwise. An SVG or web page a podcast sent ran its script as iPX, signed in as whoever opened it (#319).
|
||||
- An OPML nested more than 100 levels deep is refused, uploaded or fetched. A deep enough one stopped iPX, and a subscribed one stopped it again each time it restarted (#322).
|
||||
- A `.torrent` file larger than 10 MB, or slower than the torrent stall limit, is refused. iPX read one into memory however large it was (#330).
|
||||
- Feeds, episodes, artwork and `.torrent` files are no longer fetched from internal addresses (loopback, the LAN, Docker's networks), redirects included, unless config.toml's `fetch_internal` lists them. Adding a feed could read what a service inside the network answered (#320).
|
||||
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
||||
- A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321).
|
||||
- A show kept from a private OPML or Patreon list stays private, with the list's username and password, once the list is removed or left. It became public and was shared to the directory (#332).
|
||||
- A feed's username and password go to its own host alone: not to its episodes on other hosts, nor to the feeds an OPML lists on other hosts, which took the OPML's password on every check (#327, #328).
|
||||
- A check of one feed asks at most eight hosts whether its artwork is on https. A feed could name artwork on as many hosts as it liked, each asked in turn for up to 20 seconds, and hold up every other feed's check for hours (#339).
|
||||
- An OPML or Patreon list may bring in up to 2,000 feeds; one with more shows an error and adds none. A list of tens of thousands held up checking for everyone, and adding its feeds read the whole feeds table once for each (#337).
|
||||
- A request that changes something is refused when a browser sent it from another site. A page on another site, a sibling subdomain included, could mark everything read, start downloads, subscribe you to a Directory feed or sign you out (#336).
|
||||
- `ipx user passwd` signs the account out of every browser, and says how many of its API tokens still work. A session signed in with the old password went on working (#334).
|
||||
- Live updates about a feed go only to the people who read it, and admins. Everyone signed in had every feed's, errors included, which carried a paid feed's address with its key (#325).
|
||||
- An open page stops getting live updates within half a minute of its account being removed. They went on until the page was closed (#335).
|
||||
- Only someone subscribed to a feed, or an admin, may mark it private. Anyone signed in could keep anyone's feed, or a whole OPML's, out of the directory, and only an admin could undo it (#333).
|
||||
- An OPML upload may add up to 2,000 feeds; one with more is refused and adds none. Importing tens of thousands held up every change to the feed list for minutes and left them all in it (#338).
|
||||
- Password checks stay a few at a time when the person signing in gives up before the answer. Each sign-in that hung up let another start, so a flood of them used every core (#324).
|
||||
- Jev picks at most 20 icons an hour for each person, on the server's key; past that the page says to choose one by hand. Anyone signed in could have it pick without end, on the server's bill, holding back everyone's Signals (#340).
|
||||
- Each person's Signals on an item are judged in a call of their own, on the server's key too. One person's description was in the question that decided another's verdicts (#343).
|
||||
|
||||
## [1.4.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- An item whose feed sends Markdown beside its HTML, as Scripting News does with `<source:markdown>`, keeps it: `markdown` and `markdown_html` in the API's items (#285).
|
||||
- An item written in Markdown shows formatted, not as Markdown's raw characters: a description in Markdown, or, with no other text, RSS's `<source:markdown>`. HTML a feed sends is left as it is (#284).
|
||||
- A show from an OPML subscription or a Patreon creator can be kept: `kept` on `PATCH /api/feeds/{id}` and in the feed list. When the OPML drops a kept show, or you leave the OPML, it stays on your list as a feed of its own (#280).
|
||||
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, by person and by whose key paid so the server's bill can be split, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275, #278).
|
||||
- An admin setting for how many days back a new Signal reads (#276).
|
||||
- A list's trace says which filter, sort and page it was asked for, and whether a search, so a slow list can be told from the others (#287).
|
||||
- A feed, an OPML subscription or a Patreon creator can be left out of All Subscriptions: `hide_from_all` on `PATCH /api/feeds/{id}` and in the feed list. Set on an OPML or creator it covers every show in it; Signals and Currently Listening still read them (#288).
|
||||
- A feed, an OPML subscription or a Patreon creator can be kept out of Signals: `hide_from_signals` on `PATCH /api/feeds/{id}` and in the feed list. Jev is not asked about its items, so they cost nothing, and they are left out of each Signal's list and counts (#289).
|
||||
- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).
|
||||
|
||||
### Changed
|
||||
|
||||
- `GET /api/feeds/{id}/entries` for an OPML subscription or a Patreon creator lists the items of the shows in it that you subscribe to, together, where it listed nothing (#283).
|
||||
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
|
||||
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
|
||||
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).
|
||||
- Signals fill faster and cost less: up to ten calls to TypeSafe at once instead of one, paced under TypeSafe's limits of 80 calls and 100,000 input tokens a second, with every call held back while TypeSafe answers 429 (#277), everyone's Signals on an item in one call on the server's key, and an item's text cut at 1,000 characters instead of 2,000 (#275).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Artwork comes quickly: an image already kept is served without asking the database again, and the database finds which feed names an image by an index instead of reading every item, which took over half a second an image (#272).
|
||||
- The Directory shows the artwork of podcasts nobody on this server subscribes to, where it drew their initials (#273).
|
||||
- Artwork a publisher sends without an image type, or as `application/octet-stream`, is shown when it is a JPEG, PNG, GIF or WebP, instead of initials (#279).
|
||||
- Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274).
|
||||
- With the downloads folder out of reach during a clean-up, as when the array stops, every downloaded episode was taken as deleted by hand and forgotten; the clean-up now leaves them alone until the folder is back (#293).
|
||||
- A feed address that serves something larger than 50 MB, such as an episode's file given as a feed, is refused as too large, instead of read whole into memory at every check (#295).
|
||||
- Searching items for `%` or `_` finds those characters, instead of treating them as wildcards that matched nearly everything (#296).
|
||||
|
||||
### Security
|
||||
|
||||
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one, in every connection ipx makes (#281).
|
||||
- The address of a file being downloaded, which for Patreon carries its signature, is sent to open pages and written to the log without its query, as other addresses already were (#292).
|
||||
|
||||
## [1.3.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
@@ -848,7 +923,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.5.0...main
|
||||
[1.5.0]: https://git.sdf1.net/rays/ipx/compare/v1.4.0...v1.5.0
|
||||
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...v1.4.0
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
ipx-feed.workspace = true
|
||||
@@ -10,7 +11,6 @@ ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
flate2.workspace = true
|
||||
atom_syndication.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
@@ -20,9 +20,7 @@ librqbit.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
opml.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
rss.workspace = true
|
||||
sea-orm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -103,11 +103,8 @@ impl Keys {
|
||||
|
||||
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||
fn store(&self, set: JwkSet) {
|
||||
let keys = set
|
||||
.keys
|
||||
.iter()
|
||||
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
||||
.collect();
|
||||
let 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());
|
||||
@@ -181,8 +178,7 @@ mod tests {
|
||||
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
||||
ok.split('.').nth(1).unwrap());
|
||||
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", ok.split('.').nth(1).unwrap());
|
||||
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||
}
|
||||
|
||||
@@ -191,16 +187,25 @@ mod tests {
|
||||
let keys = Keys::default();
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
||||
"a key not cached yet is fetched");
|
||||
let count = || {
|
||||
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
async { Ok(jwks()) }
|
||||
};
|
||||
assert_eq!(
|
||||
keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(),
|
||||
Some("rays@sdf1.net"),
|
||||
"a key not cached yet is fetched"
|
||||
);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
// A made-up key id straight after: not fetched again.
|
||||
let mut h = Header::new(Algorithm::RS256);
|
||||
h.kid = Some("nobody".into());
|
||||
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||
let count = || {
|
||||
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
async { Ok(jwks()) }
|
||||
};
|
||||
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
||||
//! of each publisher's server for every visit, and still there when a publisher is not.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
||||
@@ -25,16 +25,16 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
|
||||
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
|
||||
return false;
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
||||
}
|
||||
|
||||
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
||||
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
|
||||
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
|
||||
static CHECKS: std::sync::LazyLock<tokio::sync::Semaphore> = std::sync::LazyLock::new(|| {
|
||||
tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)))
|
||||
static CHECKS: std::sync::LazyLock<std::sync::Arc<tokio::sync::Semaphore>> = std::sync::LazyLock::new(|| {
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(
|
||||
std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)),
|
||||
))
|
||||
});
|
||||
|
||||
/// What a name that is not an account is checked against, so that refusing it takes as long as
|
||||
@@ -47,12 +47,18 @@ static DECOY: std::sync::LazyLock<String> =
|
||||
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
|
||||
/// a time; see `CHECKS`.
|
||||
pub async fn check_password(password: String, stored: Option<String>) -> bool {
|
||||
let Ok(_turn) = CHECKS.acquire().await else { return false };
|
||||
tokio::task::spawn_blocking(move || match stored {
|
||||
Some(h) => verify_password(&password, &h),
|
||||
None => {
|
||||
verify_password(&password, &DECOY);
|
||||
false
|
||||
let Ok(turn) = CHECKS.clone().acquire_owned().await else { return false };
|
||||
// The turn goes with the check, not with this future: a client that hung up dropped the
|
||||
// future, freeing the turn while the check ran on, so disconnecting sign-ins ran any number
|
||||
// at once (#324).
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _turn = turn;
|
||||
match stored {
|
||||
Some(h) => verify_password(&password, &h),
|
||||
None => {
|
||||
verify_password(&password, &DECOY);
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -50,6 +50,16 @@ pub struct General {
|
||||
pub directory_token: String,
|
||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||
pub directory_share: bool,
|
||||
/// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than
|
||||
/// on each person's own (#271). Without that key it changes nothing.
|
||||
pub signals_on_server: bool,
|
||||
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a
|
||||
/// month of production's feeds was about 8,000.
|
||||
pub signal_days: u64,
|
||||
/// Internal addresses and networks feeds, episodes and artwork may still be fetched from, as
|
||||
/// `["192.168.1.20", "10.0.0.0/8"]`: everything off the public internet is refused otherwise,
|
||||
/// since a feed or anyone signed in names what is fetched (#320). Addresses, not names.
|
||||
pub fetch_internal: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -207,19 +217,16 @@ impl Default for General {
|
||||
directory_url: String::new(),
|
||||
directory_token: String::new(),
|
||||
directory_share: false,
|
||||
signals_on_server: false,
|
||||
signal_days: 7,
|
||||
fetch_internal: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Torrent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
seed_ratio: 1.0,
|
||||
seed_time_mins: 60,
|
||||
port_range: "6881-6889".into(),
|
||||
stall_mins: 30,
|
||||
}
|
||||
Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +300,27 @@ impl Feed {
|
||||
pub fn auth(&self) -> Option<(&str, Option<String>)> {
|
||||
self.username.as_deref().map(|u| (u, self.password()))
|
||||
}
|
||||
|
||||
/// `auth()`, for a URL on the feed's own host only. The credential is for whoever issued it,
|
||||
/// and an enclosure, or a feed an OPML lists, can be on any host its author names (#327, #328).
|
||||
pub fn auth_for(&self, url: &str) -> Option<(&str, Option<String>)> {
|
||||
same_host(&self.url, url).then(|| self.auth()).flatten()
|
||||
}
|
||||
|
||||
/// The username, password and password_env a feed at `url` takes from this one, its OPML or
|
||||
/// Patreon list: all of them on the list's own host, none elsewhere.
|
||||
pub fn credentials_for(&self, url: &str) -> (Option<String>, Option<String>, Option<String>) {
|
||||
if same_host(&self.url, url) {
|
||||
(self.username.clone(), self.password.clone(), self.password_env.clone())
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_host(a: &str, b: &str) -> bool {
|
||||
let host = |u: &str| url::Url::parse(u).ok().and_then(|u| u.host_str().map(str::to_ascii_lowercase));
|
||||
host(a).is_some_and(|h| host(b).as_ref() == Some(&h))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -301,14 +329,11 @@ impl Config {
|
||||
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut cfg: Self =
|
||||
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||
@@ -331,6 +356,14 @@ pub struct Stored {
|
||||
pub directory_token: String,
|
||||
#[serde(default)]
|
||||
pub directory_share: bool,
|
||||
#[serde(default)]
|
||||
pub signals_on_server: bool,
|
||||
#[serde(default = "signal_days")]
|
||||
pub signal_days: u64,
|
||||
}
|
||||
|
||||
fn signal_days() -> u64 {
|
||||
General::default().signal_days
|
||||
}
|
||||
|
||||
fn art_cache_mb() -> u64 {
|
||||
@@ -338,7 +371,7 @@ fn art_cache_mb() -> u64 {
|
||||
}
|
||||
|
||||
/// `[general]` keys that live in the database once it holds the configuration.
|
||||
const STORED_KEYS: [&str; 9] = [
|
||||
const STORED_KEYS: [&str; 11] = [
|
||||
"schedule",
|
||||
"max_total_gb",
|
||||
"max_age_days",
|
||||
@@ -348,6 +381,8 @@ const STORED_KEYS: [&str; 9] = [
|
||||
"directory_url",
|
||||
"directory_token",
|
||||
"directory_share",
|
||||
"signals_on_server",
|
||||
"signal_days",
|
||||
];
|
||||
|
||||
impl Stored {
|
||||
@@ -363,6 +398,8 @@ impl Stored {
|
||||
directory_url: g.directory_url.clone(),
|
||||
directory_token: g.directory_token.clone(),
|
||||
directory_share: g.directory_share,
|
||||
signals_on_server: g.signals_on_server,
|
||||
signal_days: g.signal_days,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +414,8 @@ impl Stored {
|
||||
g.directory_url = self.directory_url;
|
||||
g.directory_token = self.directory_token;
|
||||
g.directory_share = self.directory_share;
|
||||
g.signals_on_server = self.signals_on_server;
|
||||
g.signal_days = self.signal_days;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,20 +522,17 @@ pub fn slug(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// `slug`, with a numeric suffix if that id is taken.
|
||||
pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
|
||||
pub fn unique_slug(text: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
let base = slug(text);
|
||||
if !taken.contains_key(&base) {
|
||||
if !taken(&base) {
|
||||
return base;
|
||||
}
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap()
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).unwrap()
|
||||
}
|
||||
|
||||
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
|
||||
fn xdg(var: &str, fallback: &str) -> PathBuf {
|
||||
std::env::var_os(var)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home().join(fallback))
|
||||
std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home().join(fallback))
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
@@ -514,6 +550,22 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feeds_password_goes_to_its_own_host_alone() {
|
||||
let paid = Feed {
|
||||
url: "https://Members.example.com/feed.xml".into(),
|
||||
username: Some("me".into()),
|
||||
password: Some("pw".into()),
|
||||
..toml::from_str("url = 'x'").unwrap()
|
||||
};
|
||||
assert_eq!(paid.auth_for("https://members.example.com/ep1.mp3"), Some(("me", Some("pw".into()))));
|
||||
assert_eq!(paid.auth_for("https://cdn.example.net/ep1.mp3"), None, "an enclosure elsewhere (#328)");
|
||||
assert_eq!(paid.auth_for("https://members.example.com.evil.net/x"), None);
|
||||
assert_eq!(paid.auth_for("not a url"), None);
|
||||
assert_eq!(paid.credentials_for("https://members.example.com/other.xml").0.as_deref(), Some("me"));
|
||||
assert_eq!(paid.credentials_for("https://elsewhere.org/feed.xml"), (None, None, None), "an OPML's feed (#327)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
||||
let cfg: Config = toml::from_str(
|
||||
@@ -575,10 +627,20 @@ mod tests {
|
||||
#[test]
|
||||
fn intervals_parse_from_the_forms_people_actually_type() {
|
||||
for (input, want) in [
|
||||
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
|
||||
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
|
||||
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
|
||||
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
|
||||
("every 30m", 30),
|
||||
("30m", 30),
|
||||
("30", 30),
|
||||
("every 30 minutes", 30),
|
||||
("every 4h", 240),
|
||||
("4h", 240),
|
||||
("4 hours", 240),
|
||||
("EVERY 4H", 240),
|
||||
("1d", 1440),
|
||||
("every 2 days", 2880),
|
||||
(" every 90m ", 90),
|
||||
("1w", 10080),
|
||||
("every 2 weeks", 20160),
|
||||
("2 w", 20160),
|
||||
] {
|
||||
assert_eq!(parse_interval(input), Some(want), "{input:?}");
|
||||
}
|
||||
@@ -650,12 +712,27 @@ mod tests {
|
||||
assert!(slug(&"x".repeat(100)).len() <= 40);
|
||||
|
||||
let mut taken = BTreeMap::new();
|
||||
taken.insert("the-daily".to_string(), Feed {
|
||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||
auto_download: true, max_new_per_check: None, username: None,
|
||||
password: None, password_env: None, category: None, listed: false, private: false,
|
||||
});
|
||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||
taken.insert(
|
||||
"the-daily".to_string(),
|
||||
Feed {
|
||||
url: "u".into(),
|
||||
folder: None,
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(unique_slug("The Daily", |s| taken.contains_key(s)), "the-daily-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
1090
crates/ipx/src/db.rs
1090
crates/ipx/src/db.rs
File diff suppressed because it is too large
Load Diff
@@ -164,8 +164,7 @@ pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u6
|
||||
}
|
||||
let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
|
||||
// Something between two frames (an ID3 tag partway through) ends a piece too.
|
||||
let joins = !dropped
|
||||
&& run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
|
||||
let joins = !dropped && run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
|
||||
if !joins && let Some(r) = run.take() {
|
||||
push(&mut out, r);
|
||||
}
|
||||
@@ -261,9 +260,7 @@ impl Wanting {
|
||||
}
|
||||
|
||||
pub fn wants(&self, user: i64, feed: &str) -> bool {
|
||||
self.people
|
||||
.get(&user)
|
||||
.is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
|
||||
self.people.get(&user).is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
|
||||
}
|
||||
|
||||
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
|
||||
@@ -415,14 +412,15 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
||||
let todo = ctx.db.deadair_todo().await?;
|
||||
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
|
||||
// per-file subscription and finished checks below.
|
||||
let wanted = |t: &crate::db::DeadAirTodo| !failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id));
|
||||
let wanted = |t: &crate::db::DeadAirTodo| {
|
||||
!failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id))
|
||||
};
|
||||
st.deadair_backlog.store(todo.iter().filter(|t| wanted(t)).count() as i64, Relaxed);
|
||||
for t in todo {
|
||||
if failed.contains(&t.id) {
|
||||
continue;
|
||||
}
|
||||
let users: Vec<i64> =
|
||||
wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
|
||||
let users: Vec<i64> = wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
|
||||
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
|
||||
continue;
|
||||
}
|
||||
@@ -446,11 +444,18 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
ctx.db.store_plans(t.id, &serde_json::to_string(&plans)?).instrument(span.in_scope(|| step("store_plans"))).await?;
|
||||
ctx.db
|
||||
.store_plans(t.id, &serde_json::to_string(&plans)?)
|
||||
.instrument(span.in_scope(|| step("store_plans")))
|
||||
.await?;
|
||||
let usual = plans.get(&DEFAULT_PAUSE);
|
||||
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
|
||||
let saved_ms = usual.map_or(0, Plan::saved_ms);
|
||||
ctx.out.emit(crate::ipc::Event::DeadairDone { feed: t.feed_id.clone(), enclosure: t.id, saved_s: saved_ms / 1000 });
|
||||
ctx.out.emit(crate::ipc::Event::DeadairDone {
|
||||
feed: t.feed_id.clone(),
|
||||
enclosure: t.id,
|
||||
saved_s: saved_ms / 1000,
|
||||
});
|
||||
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
|
||||
tracing::info!(
|
||||
feed = t.feed_id,
|
||||
@@ -472,8 +477,17 @@ pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
|
||||
let Some((first, frame)) = probe(src).instrument(step("probe")).await? else { return Ok(None) };
|
||||
let path = src.display().to_string();
|
||||
let log = ffmpeg(&[
|
||||
"-hide_banner", "-nostats", "-i", &path, "-map", "0:a:0",
|
||||
"-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
|
||||
"-hide_banner",
|
||||
"-nostats",
|
||||
"-i",
|
||||
&path,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-af",
|
||||
&format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"),
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
])
|
||||
.instrument(step("silencedetect"))
|
||||
.await
|
||||
@@ -492,16 +506,15 @@ pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
|
||||
|
||||
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
|
||||
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
||||
let out = run_out("ffprobe", &[
|
||||
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path,
|
||||
])
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path],
|
||||
)
|
||||
.await?;
|
||||
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
|
||||
out.lines()
|
||||
.map(|l| {
|
||||
let field = |k: &str| {
|
||||
l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok())
|
||||
};
|
||||
let field = |k: &str| l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok());
|
||||
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
|
||||
})
|
||||
.collect()
|
||||
@@ -511,26 +524,44 @@ async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
||||
/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
|
||||
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
|
||||
let path = src.display().to_string();
|
||||
let out = run_out("ffprobe", &[
|
||||
"-v", "error", "-show_entries",
|
||||
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic", "-of", "json", &path,
|
||||
])
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic",
|
||||
"-of",
|
||||
"json",
|
||||
&path,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
|
||||
let streams = v["streams"].as_array().cloned().unwrap_or_default();
|
||||
let video = streams
|
||||
.iter()
|
||||
.any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
||||
let video = streams.iter().any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
||||
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
|
||||
let format = v["format"]["format_name"].as_str().unwrap_or("");
|
||||
let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
|
||||
if video || !(plain("mp3") || plain("aac")) {
|
||||
return Ok(None);
|
||||
}
|
||||
let out = run_out("ffprobe", &[
|
||||
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pts_time,duration_time",
|
||||
"-read_intervals", "%+#1", "-of", "csv=p=0", &path,
|
||||
])
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"packet=pts_time,duration_time",
|
||||
"-read_intervals",
|
||||
"%+#1",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
&path,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
|
||||
match (f.next(), f.next()) {
|
||||
@@ -682,9 +713,16 @@ mod tests {
|
||||
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
|
||||
let src = dir.join(format!("t.{ext}"));
|
||||
ffmpeg(&[
|
||||
"-v", "error", "-y", "-f", "lavfi", "-i",
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"aevalsrc='if(lt(mod(t,3),1),0.5*sin(2*PI*440*t),0)':d=12:s=44100",
|
||||
"-c:a", codec, &src.display().to_string(),
|
||||
"-c:a",
|
||||
codec,
|
||||
&src.display().to_string(),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -707,18 +745,36 @@ mod tests {
|
||||
let len = |p: &Path| {
|
||||
let p = p.display().to_string();
|
||||
async move {
|
||||
run_out("ffprobe", &["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=duration_time", "-of", "csv=p=0", &p])
|
||||
.await
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
|
||||
.sum::<f64>()
|
||||
run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"packet=duration_time",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
&p,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
|
||||
.sum::<f64>()
|
||||
}
|
||||
};
|
||||
let short = len(&src).await - len(&list).await;
|
||||
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
|
||||
// Decodes without a complaint.
|
||||
assert!(ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
|
||||
assert!(
|
||||
ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"])
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Saved as a file of its own (#186): as long as the playlist, and it plays.
|
||||
let (n, body) = kept(&src, p).await.unwrap();
|
||||
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
|
||||
@@ -730,7 +786,12 @@ mod tests {
|
||||
let saved = dir.join(format!("saved.{ext}"));
|
||||
std::fs::write(&saved, &bytes).unwrap();
|
||||
assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}");
|
||||
assert!(ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
|
||||
assert!(
|
||||
ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"])
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -750,6 +811,11 @@ async fn real_file() {
|
||||
let name = src.file_name().unwrap().to_string_lossy().into_owned();
|
||||
for (pause, p) in &plans {
|
||||
std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
|
||||
eprintln!("{pause} ms: {} cuts, {:.1} min saved, {} pieces", p.cuts.len(), p.saved_ms() as f64 / 60_000.0, p.pieces.len());
|
||||
eprintln!(
|
||||
"{pause} ms: {} cuts, {:.1} min saved, {} pieces",
|
||||
p.cuts.len(),
|
||||
p.saved_ms() as f64 / 60_000.0,
|
||||
p.pieces.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,12 @@ pub enum DirError {
|
||||
impl std::fmt::Display for DirError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DirError::NotConfigured => write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory."),
|
||||
DirError::Refused(401, _) => write!(f, "The directory does not accept this server's token. It may have been revoked."),
|
||||
DirError::NotConfigured => {
|
||||
write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory.")
|
||||
}
|
||||
DirError::Refused(401, _) => {
|
||||
write!(f, "The directory does not accept this server's token. It may have been revoked.")
|
||||
}
|
||||
DirError::Refused(403, msg) => write!(f, "The directory refused: {msg}."),
|
||||
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
|
||||
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
|
||||
@@ -58,9 +62,18 @@ pub fn configured(cfg: &config::Config) -> bool {
|
||||
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
|
||||
/// has answered, so a slow read of a large answer showed under no span (#250).
|
||||
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
|
||||
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
|
||||
async fn call<T: DeserializeOwned>(
|
||||
ctx: &Ctx,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<&[SnapshotEntry]>,
|
||||
) -> Result<T, DirError> {
|
||||
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
|
||||
let mut req = ctx.client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(20));
|
||||
let mut req = ctx
|
||||
.local_client
|
||||
.request(method, format!("{base}{path}"))
|
||||
.bearer_auth(token)
|
||||
.timeout(std::time::Duration::from_secs(20));
|
||||
// The directory's spans join this request's trace (#213).
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
@@ -80,13 +93,19 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
|
||||
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let msg = resp.json::<serde_json::Value>().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)).unwrap_or_default();
|
||||
let msg = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
return Err(DirError::Refused(status.as_u16(), msg));
|
||||
}
|
||||
// A removal or an unblock answers 204, with nothing to read: that reads as null, which is ().
|
||||
let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
tracing::Span::current().record("http.response.body.size", body.len());
|
||||
serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||
serde_json::from_slice(if body.is_empty() { b"null" } else { &body })
|
||||
.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||
}
|
||||
|
||||
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
|
||||
@@ -98,15 +117,37 @@ pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
|
||||
}
|
||||
|
||||
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/feeds", None).await
|
||||
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/feeds", None).await?;
|
||||
note_art(got.iter());
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await
|
||||
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await?;
|
||||
note_art(got.iter());
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
|
||||
call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await
|
||||
let got: FeedPage = call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await?;
|
||||
note_art(std::iter::once(&got.listing));
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
/// Artwork the directory has listed, which `/api/art` serves though no feed here names it: the
|
||||
/// Directory shows podcasts nobody here subscribes to, and drew their initials instead (#273).
|
||||
///
|
||||
/// shortcut: in memory, one address a listed feed, forgotten on a restart until the Directory is
|
||||
/// next read, which the page does when it loads; a table if that ever shows as initials.
|
||||
static LISTED_ART: std::sync::LazyLock<std::sync::Mutex<HashSet<String>>> = std::sync::LazyLock::new(Default::default);
|
||||
|
||||
fn note_art<'a>(listings: impl Iterator<Item = &'a Listing>) {
|
||||
let mut art = LISTED_ART.lock().unwrap_or_else(|e| e.into_inner());
|
||||
art.extend(listings.filter_map(|l| l.image.clone()));
|
||||
}
|
||||
|
||||
pub fn listed_art(url: &str) -> bool {
|
||||
LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()).contains(url)
|
||||
}
|
||||
|
||||
// Administering the directory (#206): each needs an rwa token, which the directory checks.
|
||||
@@ -211,7 +252,8 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
|
||||
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
|
||||
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
|
||||
if forced || resend(last.as_ref(), &key) {
|
||||
let span = tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
||||
let span =
|
||||
tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
||||
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
|
||||
if got.is_ok() {
|
||||
last = Some((key, std::time::Instant::now()));
|
||||
@@ -225,7 +267,12 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
|
||||
};
|
||||
if let Some(got) = got {
|
||||
match &got {
|
||||
Ok(r) => tracing::info!(accepted = r.accepted, refused_private = r.refused_private, refused_blocked = r.refused_blocked, "shared with the directory"),
|
||||
Ok(r) => tracing::info!(
|
||||
accepted = r.accepted,
|
||||
refused_private = r.refused_private,
|
||||
refused_blocked = r.refused_blocked,
|
||||
"shared with the directory"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
|
||||
}
|
||||
let (result, error) = match got {
|
||||
@@ -254,6 +301,23 @@ fn resend(last: Option<&(Vec<u8>, std::time::Instant)>, key: &[u8]) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artwork_the_directory_listed_is_let_through_and_nothing_else() {
|
||||
let listing = |image: Option<&str>| Listing {
|
||||
id: "x".into(),
|
||||
url: "https://x.example/feed".into(),
|
||||
title: None,
|
||||
image: image.map(str::to_owned),
|
||||
category: None,
|
||||
subcategory: None,
|
||||
podcast: true,
|
||||
subscribers: 1,
|
||||
};
|
||||
note_art([listing(Some("https://img.example/cover.jpg")), listing(None)].iter());
|
||||
assert!(listed_art("https://img.example/cover.jpg"));
|
||||
assert!(!listed_art("http://192.168.1.1/admin"));
|
||||
}
|
||||
|
||||
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
|
||||
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
|
||||
edit(&mut cfg);
|
||||
|
||||
@@ -85,16 +85,8 @@ pub fn filename_for(url: &str, content_disposition: Option<&str>) -> String {
|
||||
}
|
||||
let from_url = url::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.path_segments()
|
||||
.and_then(|s| s.filter(|p| !p.is_empty()).next_back())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.map(|seg| {
|
||||
percent_encoding::percent_decode_str(&seg)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.and_then(|u| u.path_segments().and_then(|mut s| s.rfind(|p| !p.is_empty())).map(str::to_owned))
|
||||
.map(|seg| percent_encoding::percent_decode_str(&seg).decode_utf8_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
sanitize(&from_url)
|
||||
}
|
||||
@@ -105,11 +97,7 @@ fn disposition_filename(header: &str) -> Option<String> {
|
||||
// RFC 5987 form wins when both are present.
|
||||
if let Some(v) = part.strip_prefix("filename*=") {
|
||||
let raw = v.rsplit('\'').next().unwrap_or(v);
|
||||
return Some(
|
||||
percent_encoding::percent_decode_str(raw)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
return Some(percent_encoding::percent_decode_str(raw).decode_utf8_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
for part in header.split(';') {
|
||||
@@ -145,8 +133,8 @@ pub async fn download(
|
||||
mut on_progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Downloaded> {
|
||||
let mut req = client.get(url);
|
||||
if let Some(user) = &feed_cfg.username {
|
||||
req = req.basic_auth(user, feed_cfg.password());
|
||||
if let Some((user, password)) = feed_cfg.auth_for(url) {
|
||||
req = req.basic_auth(user, password);
|
||||
}
|
||||
let resp = req.send().await.context("connecting")?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -155,21 +143,15 @@ pub async fn download(
|
||||
|
||||
let name = filename_for(
|
||||
resp.url().as_str(),
|
||||
resp.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
resp.headers().get(reqwest::header::CONTENT_DISPOSITION).and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let total = resp.content_length();
|
||||
|
||||
let tmp_dir = cfg.general.download_dir.join(".ipx-incomplete");
|
||||
tokio::fs::create_dir_all(&tmp_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
||||
tokio::fs::create_dir_all(&tmp_dir).await.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
||||
let tmp = tmp_dir.join(&name);
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", tmp.display()))?;
|
||||
let mut file = tokio::fs::File::create(&tmp).await.with_context(|| format!("creating {}", tmp.display()))?;
|
||||
let mut done: u64 = 0;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
|
||||
@@ -199,13 +181,9 @@ pub async fn download(
|
||||
/// Moves a completed download into its folder. Same filesystem as the incomplete dir, so
|
||||
/// this is a rename rather than the original's copy-then-unlink.
|
||||
pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
tokio::fs::create_dir_all(dir).await.with_context(|| format!("creating {}", dir.display()))?;
|
||||
let dest = unique_path(dir, &d.name);
|
||||
tokio::fs::rename(&d.tmp, &dest)
|
||||
.await
|
||||
.with_context(|| format!("moving into {}", dest.display()))?;
|
||||
tokio::fs::rename(&d.tmp, &dest).await.with_context(|| format!("moving into {}", dest.display()))?;
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
@@ -281,15 +259,8 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
let stem = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(name);
|
||||
let ext = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{e}"))
|
||||
.unwrap_or_default();
|
||||
let stem = Path::new(name).file_stem().and_then(|s| s.to_str()).unwrap_or(name);
|
||||
let ext = Path::new(name).extension().and_then(|e| e.to_str()).map(|e| format!(".{e}")).unwrap_or_default();
|
||||
for n in 2..1000 {
|
||||
let candidate = dir.join(format!("{stem}-{n}{ext}"));
|
||||
if !candidate.exists() {
|
||||
@@ -308,12 +279,7 @@ pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str
|
||||
match cfg.general.organize {
|
||||
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
|
||||
Organize::Feed => {
|
||||
let raw = feed_cfg
|
||||
.folder
|
||||
.as_deref()
|
||||
.or(title)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(id);
|
||||
let raw = feed_cfg.folder.as_deref().or(title).filter(|s| !s.trim().is_empty()).unwrap_or(id);
|
||||
raw.split('/')
|
||||
.map(str::trim)
|
||||
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
|
||||
@@ -410,19 +376,13 @@ mod tests {
|
||||
"ep1.mp3",
|
||||
"query string is not part of the name"
|
||||
);
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None),
|
||||
"My Show Ep 1.mp3"
|
||||
);
|
||||
assert_eq!(filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None), "My Show Ep 1.mp3");
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/dl?id=9", Some("attachment; filename=\"real name.mp3\"")),
|
||||
"real name.mp3",
|
||||
"Content-Disposition overrides the URL"
|
||||
);
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")),
|
||||
"café.mp3"
|
||||
);
|
||||
assert_eq!(filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")), "café.mp3");
|
||||
assert_eq!(filename_for("https://x.com/", None), "download");
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/a/../../etc/passwd", None),
|
||||
@@ -436,9 +396,21 @@ mod tests {
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = "/tmp".into();
|
||||
let mut f = crate::config::Feed {
|
||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
||||
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false, private: false,
|
||||
url: "u".into(),
|
||||
folder: Some("Subscriptions/Some | Show".into()),
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||
|
||||
|
||||
@@ -78,6 +78,9 @@ pub mod entries {
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
pub first_seen: i64,
|
||||
/// The item's Markdown, when its feed sends it beside the HTML (#285).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub markdown: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub image: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
@@ -216,6 +219,21 @@ pub mod subscriptions {
|
||||
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub pinned: bool,
|
||||
/// A feed from an OPML or Patreon list this person keeps when the list drops it, or when
|
||||
/// they leave the list: it becomes a feed of its own instead of going (#280).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub kept: bool,
|
||||
/// Left out of All Subscriptions: this feed, or on an OPML or Patreon creator every show
|
||||
/// in it (#288).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_all: bool,
|
||||
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_signals: bool,
|
||||
/// Every item of the feed first seen before this is read by this person (#347): unread
|
||||
/// lists start here instead of reading the whole back catalogue. NULL knows nothing, and
|
||||
/// the daily reap moves it up (`Db::advance_unread_from`).
|
||||
pub unread_from: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
@@ -333,6 +351,10 @@ pub mod signal_verdicts {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
pub hit: bool,
|
||||
/// A picked-out item to ask about again, since its Signal changed (#276). It stays in the
|
||||
/// Signal's list until Jev has answered the new question.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub recheck: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -11,13 +11,31 @@ use tokio::sync::{broadcast, mpsc};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "ev", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
FeedStart { feed: String },
|
||||
FeedSkip { feed: String, reason: String },
|
||||
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
||||
FeedError { feed: String, msg: String },
|
||||
FeedStart {
|
||||
feed: String,
|
||||
},
|
||||
FeedSkip {
|
||||
feed: String,
|
||||
reason: String,
|
||||
},
|
||||
FeedDone {
|
||||
feed: String,
|
||||
new: usize,
|
||||
downloaded: usize,
|
||||
failed: usize,
|
||||
torrents: usize,
|
||||
},
|
||||
FeedError {
|
||||
feed: String,
|
||||
msg: String,
|
||||
},
|
||||
/// The feed answered from a new address after redirects that all said it moved for good,
|
||||
/// and the catalogue now has that address (#115).
|
||||
FeedMoved { feed: String, from: String, to: String },
|
||||
FeedMoved {
|
||||
feed: String,
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
Progress {
|
||||
feed: String,
|
||||
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
||||
@@ -29,14 +47,38 @@ pub enum Event {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total: Option<u64>,
|
||||
},
|
||||
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
|
||||
DownloadError { feed: String, enclosure: i64, url: String, msg: String },
|
||||
TorrentDeferred { feed: String, url: String },
|
||||
DownloadDone {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
url: String,
|
||||
path: String,
|
||||
bytes: u64,
|
||||
},
|
||||
DownloadError {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
url: String,
|
||||
msg: String,
|
||||
},
|
||||
TorrentDeferred {
|
||||
feed: String,
|
||||
url: String,
|
||||
},
|
||||
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
|
||||
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
|
||||
DeadairDone {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
saved_s: i64,
|
||||
},
|
||||
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
||||
SignalHits { user: i64, hits: usize },
|
||||
Reaped { path: String, bytes: u64 },
|
||||
SignalHits {
|
||||
user: i64,
|
||||
hits: usize,
|
||||
},
|
||||
Reaped {
|
||||
path: String,
|
||||
bytes: u64,
|
||||
},
|
||||
/// Terminal: a client that asked for work stops reading here.
|
||||
ScanDone {
|
||||
feeds: usize,
|
||||
@@ -47,12 +89,53 @@ pub enum Event {
|
||||
#[serde(default)]
|
||||
forced: bool,
|
||||
},
|
||||
ReapDone { files: usize, bytes: u64 },
|
||||
Status { feeds: usize, pending: i64, downloaded: i64 },
|
||||
Error { msg: String },
|
||||
ReapDone {
|
||||
files: usize,
|
||||
bytes: u64,
|
||||
},
|
||||
Status {
|
||||
feeds: usize,
|
||||
pending: i64,
|
||||
downloaded: i64,
|
||||
},
|
||||
Error {
|
||||
msg: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The feed this is about, for sending it only to those who read that feed.
|
||||
pub fn feed(&self) -> Option<&str> {
|
||||
match self {
|
||||
Event::FeedStart { feed }
|
||||
| Event::FeedSkip { feed, .. }
|
||||
| Event::FeedDone { feed, .. }
|
||||
| Event::FeedError { feed, .. }
|
||||
| Event::FeedMoved { feed, .. }
|
||||
| Event::Progress { feed, .. }
|
||||
| Event::DownloadDone { feed, .. }
|
||||
| Event::DownloadError { feed, .. }
|
||||
| Event::TorrentDeferred { feed, .. }
|
||||
| Event::DeadairDone { feed, .. } => Some(feed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a page signed in as this person is sent it. Everyone had every event, so a key in
|
||||
/// a paid feed's path, which `redacted` keeps, reached people who do not read the feed (#325).
|
||||
/// An admin has them all, for the log.
|
||||
pub fn is_for(&self, user: &crate::db::User, mine: &std::collections::HashSet<String>) -> bool {
|
||||
if user.is_admin {
|
||||
return true;
|
||||
}
|
||||
match self {
|
||||
Event::SignalHits { user: u, .. } => *u == user.id,
|
||||
// Paths of files on disk, everyone's.
|
||||
Event::Reaped { .. } => false,
|
||||
ev => ev.feed().is_none_or(|f| mine.contains(f)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. })
|
||||
}
|
||||
@@ -80,10 +163,9 @@ impl Event {
|
||||
Event::Reaped { path, bytes } => {
|
||||
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::ReapDone { files, bytes } => format!(
|
||||
"deleted {files} old file(s), {:.1} MB",
|
||||
*bytes as f64 / 1_048_576.0
|
||||
),
|
||||
Event::ReapDone { files, bytes } => {
|
||||
format!("deleted {files} old file(s), {:.1} MB", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::Status { feeds, pending, downloaded } => {
|
||||
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
|
||||
}
|
||||
@@ -91,7 +173,9 @@ impl Event {
|
||||
// Noise in a terminal; a UI still gets them on the socket.
|
||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
||||
Event::DeadairDone { feed, saved_s, .. } => {
|
||||
format!("{feed}: DeadAir found the silences, {saved_s} s shorter")
|
||||
}
|
||||
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
|
||||
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||
})
|
||||
@@ -165,7 +249,9 @@ impl Emitter {
|
||||
// An error here only means nobody is listening yet.
|
||||
let _ = tx.send(e.clone());
|
||||
}
|
||||
if self.print && let Some(line) = e.human() {
|
||||
if self.print
|
||||
&& let Some(line) = e.human()
|
||||
{
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
@@ -182,7 +268,10 @@ impl Event {
|
||||
*url = redact(url);
|
||||
*msg = redact_text(msg);
|
||||
}
|
||||
Event::TorrentDeferred { url, .. } => *url = redact(url),
|
||||
// Progress and DownloadDone too: a Patreon file's address carries its signature (#292).
|
||||
Event::TorrentDeferred { url, .. } | Event::Progress { url, .. } | Event::DownloadDone { url, .. } => {
|
||||
*url = redact(url)
|
||||
}
|
||||
Event::FeedMoved { from, to, .. } => {
|
||||
*from = redact(from);
|
||||
*to = redact(to);
|
||||
@@ -262,9 +351,8 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
||||
/// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended
|
||||
/// any `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
||||
/// A future, since reading the counts is a database query.
|
||||
pub type StatusFn = std::sync::Arc<
|
||||
dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync,
|
||||
>;
|
||||
pub type StatusFn =
|
||||
std::sync::Arc<dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
|
||||
|
||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||
pub async fn serve(
|
||||
@@ -276,14 +364,12 @@ pub async fn serve(
|
||||
// A socket file left by a crashed daemon would block the bind; a live one was already
|
||||
// rejected by the caller's daemon_is_live() check.
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.with_context(|| format!("removing stale socket {}", path.display()))?;
|
||||
std::fs::remove_file(&path).with_context(|| format!("removing stale socket {}", path.display()))?;
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&path)
|
||||
.with_context(|| format!("binding {}", path.display()))?;
|
||||
let listener = UnixListener::bind(&path).with_context(|| format!("binding {}", path.display()))?;
|
||||
tracing::info!(socket = %path.display(), "listening");
|
||||
|
||||
loop {
|
||||
@@ -370,7 +456,9 @@ pub async fn reload(path: &Path) -> Result<()> {
|
||||
}
|
||||
anyhow::bail!("the daemon closed the connection without answering")
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), answer).await.context("the daemon did not answer in 10s")?
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), answer)
|
||||
.await
|
||||
.context("the daemon did not answer in 10s")?
|
||||
}
|
||||
|
||||
/// Sends one command to a running daemon and prints the events it produces.
|
||||
@@ -402,6 +490,31 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feeds_events_go_to_those_who_read_it() {
|
||||
let person = |id, is_admin| crate::db::User {
|
||||
id,
|
||||
name: "x".into(),
|
||||
pass_hash: None,
|
||||
is_admin,
|
||||
created: None,
|
||||
last_login: None,
|
||||
};
|
||||
let (me, admin) = (person(1, false), person(2, true));
|
||||
let mine: std::collections::HashSet<String> = ["mine".to_string()].into();
|
||||
let error = |feed: &str| Event::FeedError {
|
||||
feed: feed.into(),
|
||||
msg: "error sending request for url (https://feeds.supercast.com/feeds/KEY)".into(),
|
||||
};
|
||||
assert!(error("mine").is_for(&me, &mine));
|
||||
assert!(!error("theirs").is_for(&me, &mine), "a paid feed's key in its path (#325)");
|
||||
assert!(error("theirs").is_for(&admin, &mine), "an admin's log has everything");
|
||||
assert!(Event::ScanDone { feeds: 3, only: None, forced: false }.is_for(&me, &mine));
|
||||
assert!(Event::SignalHits { user: 1, hits: 2 }.is_for(&me, &mine));
|
||||
assert!(!Event::SignalHits { user: 3, hits: 2 }.is_for(&me, &mine));
|
||||
assert!(!Event::Reaped { path: "/downloads/Theirs/ep.mp3".into(), bytes: 1 }.is_for(&me, &mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_not_due_stays_off_the_live_updates() {
|
||||
let (tx, mut rx) = broadcast::channel(4);
|
||||
@@ -428,9 +541,28 @@ mod tests {
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
|
||||
});
|
||||
out.emit(Event::FeedMoved { feed: "f".into(), from: format!("https://x/rss?{key}"), to: format!("https://y/rss?{key}") });
|
||||
// What every open page is sent, whoever's feed it is (#238).
|
||||
for _ in 0..3 {
|
||||
out.emit(Event::FeedMoved {
|
||||
feed: "f".into(),
|
||||
from: format!("https://x/rss?{key}"),
|
||||
to: format!("https://y/rss?{key}"),
|
||||
});
|
||||
out.emit(Event::Progress {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
file: "ep.mp3".into(),
|
||||
done: 1,
|
||||
total: None,
|
||||
});
|
||||
out.emit(Event::DownloadDone {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
path: "/downloads/ep.mp3".into(),
|
||||
bytes: 1,
|
||||
});
|
||||
// What every open page is sent, whoever's feed it is (#238, #292).
|
||||
for _ in 0..5 {
|
||||
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
|
||||
assert!(!sent.contains("SECRET"), "{sent}");
|
||||
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
|
||||
@@ -441,7 +573,10 @@ mod tests {
|
||||
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
||||
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||
let wire = serde_json::to_value(&e).unwrap();
|
||||
assert_eq!((wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()), (Some("feed_moved"), Some("http://a/f"), Some("https://a/f")));
|
||||
assert_eq!(
|
||||
(wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()),
|
||||
(Some("feed_moved"), Some("http://a/f"), Some("https://a/f"))
|
||||
);
|
||||
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
|
||||
}
|
||||
|
||||
@@ -480,14 +615,8 @@ mod tests {
|
||||
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
|
||||
|
||||
// total is omitted rather than null when the server sent no length.
|
||||
let ev = Event::Progress {
|
||||
feed: "a".into(),
|
||||
enclosure: 1,
|
||||
url: "u".into(),
|
||||
file: "f".into(),
|
||||
done: 1,
|
||||
total: None,
|
||||
};
|
||||
let ev =
|
||||
Event::Progress { feed: "a".into(), enclosure: 1, url: "u".into(), file: "f".into(), done: 1, total: None };
|
||||
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
|
||||
}
|
||||
|
||||
@@ -495,14 +624,7 @@ mod tests {
|
||||
fn only_completion_events_end_a_client_session() {
|
||||
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
|
||||
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
||||
assert!(!Event::FeedDone {
|
||||
feed: "a".into(),
|
||||
new: 0,
|
||||
downloaded: 0,
|
||||
failed: 0,
|
||||
torrents: 0
|
||||
}
|
||||
.is_terminal());
|
||||
assert!(!Event::FeedDone { feed: "a".into(), new: 0, downloaded: 0, failed: 0, torrents: 0 }.is_terminal());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -27,9 +27,8 @@ struct Ring {
|
||||
next_seq: u64,
|
||||
}
|
||||
|
||||
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
|
||||
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
|
||||
});
|
||||
static BUF: LazyLock<Mutex<Ring>> =
|
||||
LazyLock::new(|| Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 }));
|
||||
|
||||
pub fn push(level: &str, target: &str, msg: String) {
|
||||
let mut ring = match BUF.lock() {
|
||||
@@ -57,12 +56,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
let latest = ring.next_seq.saturating_sub(1);
|
||||
let mut out: Vec<LogLine> = ring
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|l| l.seq > after)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut out: Vec<LogLine> = ring.lines.iter().filter(|l| l.seq > after).cloned().collect();
|
||||
// On a first load (after = 0) the tail is what matters, not the head.
|
||||
if out.len() > limit {
|
||||
out.drain(..out.len() - limit);
|
||||
@@ -130,10 +124,7 @@ mod tests {
|
||||
let (all, latest) = since(0, CAPACITY * 2);
|
||||
assert_eq!(all.len(), CAPACITY, "bounded");
|
||||
assert!(latest >= (CAPACITY + 50) as u64);
|
||||
assert!(
|
||||
all.first().unwrap().seq < all.last().unwrap().seq,
|
||||
"oldest first"
|
||||
);
|
||||
assert!(all.first().unwrap().seq < all.last().unwrap().seq, "oldest first");
|
||||
|
||||
// "everything after the last one I saw" must return nothing new.
|
||||
let (none, _) = since(latest, 100);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,17 @@ pub struct Stats {
|
||||
pub feeds: AtomicI64,
|
||||
pub pending: AtomicI64,
|
||||
pub downloaded: AtomicI64,
|
||||
/// When the Signals judge last finished a pass, Unix seconds; 0 for not since starting.
|
||||
pub signals_last_pass: AtomicI64,
|
||||
/// Calls to Jev by how they ended, in `JEV_RESULTS`' order, and their microseconds all told.
|
||||
pub jev_calls: [AtomicU64; 5],
|
||||
pub jev_micros: AtomicU64,
|
||||
/// Microseconds calls waited for their turn under TypeSafe's limits (#277), part of `jev_micros`.
|
||||
pub jev_paced_micros: AtomicU64,
|
||||
/// What each person's Signals, icons and key checks used, and whose key paid (#278): items
|
||||
/// judged, Signals they fit, and TypeSafe's input and output tokens. Floats: a call shared by
|
||||
/// several people on the server's key is split between them.
|
||||
people: std::sync::Mutex<std::collections::BTreeMap<(String, &'static str), [f64; 4]>>,
|
||||
/// The worker's job now, and since when.
|
||||
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
|
||||
/// Jobs finished, by kind: how many, and their seconds all told.
|
||||
@@ -64,11 +75,26 @@ impl Stats {
|
||||
feeds: AtomicI64::new(-1),
|
||||
pending: AtomicI64::new(-1),
|
||||
downloaded: AtomicI64::new(-1),
|
||||
signals_last_pass: AtomicI64::new(0),
|
||||
jev_calls: [const { AtomicU64::new(0) }; 5],
|
||||
jev_micros: AtomicU64::new(0),
|
||||
jev_paced_micros: AtomicU64::new(0),
|
||||
people: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||
job: std::sync::Mutex::new(None),
|
||||
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds to what `who` used on `key` ("server" or "own"): items judged, fits, input and output
|
||||
/// tokens.
|
||||
pub fn bill(&self, who: &str, key: &'static str, used: [f64; 4]) {
|
||||
let mut people = self.people.lock().unwrap();
|
||||
let row = people.entry((who.to_owned(), key)).or_default();
|
||||
for (r, u) in row.iter_mut().zip(used) {
|
||||
*r += u;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
|
||||
/// ends: finished, failed or stopped.
|
||||
pub fn job(&'static self, kind: &'static str) -> Job {
|
||||
@@ -90,6 +116,11 @@ impl Drop for Job {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a call to Jev ended: answered, its key refused (401-403), the request refused as
|
||||
/// malformed (400, 422), over TypeSafe's rate limits (429), or anything else, TypeSafe's 5xx and
|
||||
/// timeouts.
|
||||
pub const JEV_RESULTS: [&str; 5] = ["ok", "key_refused", "malformed", "rate_limited", "error"];
|
||||
|
||||
/// Counts one more of something in progress until dropped, however it ends.
|
||||
pub struct Busy(&'static AtomicI64);
|
||||
|
||||
@@ -129,15 +160,16 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
||||
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let app = axum::Router::new().route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || {
|
||||
let s = s.clone();
|
||||
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip, every 15 s (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || {
|
||||
let s = s.clone();
|
||||
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip, every 15 s (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
match tokio::net::TcpListener::from_std(listener) {
|
||||
Ok(l) => {
|
||||
if let Err(e) = axum::serve(l, app).await {
|
||||
@@ -155,22 +187,59 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
||||
pub fn render(s: &Sources) -> String {
|
||||
let mut out = String::new();
|
||||
let (open, idle, max) = s.ctx.db.pool();
|
||||
gauge(&mut out, "ipx_db_connections", "Database connections open, in use or idle.",
|
||||
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)]);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_db_connections",
|
||||
"Database connections open, in use or idle.",
|
||||
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)],
|
||||
);
|
||||
gauge(&mut out, "ipx_db_connections_max", "The most database connections the pool opens.", &[("", max as f64)]);
|
||||
gauge(&mut out, "ipx_worker_queue", "Commands waiting for the worker: scans, downloads and the like.",
|
||||
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)]);
|
||||
gauge(&mut out, "ipx_event_listeners", "Open pages and socket clients listening for live updates.",
|
||||
&[("", s.events.receiver_count() as f64)]);
|
||||
gauge(&mut out, "ipx_torrent_slots_free", "Torrents that could start now.",
|
||||
&[("", s.ctx.torrent_slots.available_permits() as f64)]);
|
||||
gauge(&mut out, "ipx_deadair_playing", "Episodes someone is playing through DeadAir's playlist.",
|
||||
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)]);
|
||||
work(&mut out, &STATS, s.events.len(), s.ctx.retry.lock().map_or(0, |r| r.len()), s.ctx.cfg().general.art_cache_mb * 1_048_576);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_worker_queue",
|
||||
"Commands waiting for the worker: scans, downloads and the like.",
|
||||
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_event_listeners",
|
||||
"Open pages and socket clients listening for live updates.",
|
||||
&[("", s.events.receiver_count() as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_torrent_slots_free",
|
||||
"Torrents that could start now.",
|
||||
&[("", s.ctx.torrent_slots.available_permits() as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_deadair_playing",
|
||||
"Episodes someone is playing through DeadAir's playlist.",
|
||||
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)],
|
||||
);
|
||||
work(
|
||||
&mut out,
|
||||
&STATS,
|
||||
s.events.len(),
|
||||
s.ctx.retry.lock().map_or(0, |r| r.len()),
|
||||
s.ctx.cfg().general.art_cache_mb * 1_048_576,
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_signals_keys_refused",
|
||||
"People whose TypeSafe key was refused, whose Signals wait until they save another.",
|
||||
&[("", s.ctx.signals_refused.lock().map_or(0, |r| r.len()) as f64)],
|
||||
);
|
||||
let rt = s.runtime.metrics();
|
||||
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
|
||||
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
|
||||
gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_runtime_queue",
|
||||
"Tasks waiting in the runtime's global queue.",
|
||||
&[("", rt.global_queue_depth() as f64)],
|
||||
);
|
||||
let (rss, fds) = ipx_directory::metrics::process();
|
||||
if let Some(v) = rss {
|
||||
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
||||
@@ -178,9 +247,24 @@ pub fn render(s: &Sources) -> String {
|
||||
if let Some(v) = fds {
|
||||
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
|
||||
}
|
||||
counter(&mut out, "ipx_feed_fetches_total", "Feeds fetched, answered 304 included.", &[("", ipx_feed::FETCHES.load(Relaxed) as f64)]);
|
||||
counter(&mut out, "ipx_feed_bytes_total", "Bytes of feed bodies fetched.", &[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)]);
|
||||
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
|
||||
counter(
|
||||
&mut out,
|
||||
"ipx_feed_fetches_total",
|
||||
"Feeds fetched, answered 304 included.",
|
||||
&[("", ipx_feed::FETCHES.load(Relaxed) as f64)],
|
||||
);
|
||||
counter(
|
||||
&mut out,
|
||||
"ipx_feed_bytes_total",
|
||||
"Bytes of feed bodies fetched.",
|
||||
&[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_uptime_seconds",
|
||||
"Seconds since the daemon started.",
|
||||
&[("", s.started.elapsed().as_secs() as f64)],
|
||||
);
|
||||
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
||||
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
||||
out
|
||||
@@ -193,34 +277,139 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
||||
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
|
||||
if let Some((kind, since)) = job {
|
||||
let label = format!("kind=\"{kind}\"");
|
||||
gauge(out, "ipx_worker_job_seconds", "How long the worker has been on its job, by the job's kind.",
|
||||
&[(&label, since.elapsed().as_secs_f64())]);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_worker_job_seconds",
|
||||
"How long the worker has been on its job, by the job's kind.",
|
||||
&[(&label, since.elapsed().as_secs_f64())],
|
||||
);
|
||||
}
|
||||
let jobs = st.jobs.lock().unwrap().clone();
|
||||
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
|
||||
let pairs = |f: fn(&(u64, f64)) -> f64| labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>();
|
||||
let pairs = |f: fn(&(u64, f64)) -> f64| {
|
||||
labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>()
|
||||
};
|
||||
counter(out, "ipx_jobs_total", "Jobs the worker has finished, by kind.", &pairs(|v| v.0 as f64));
|
||||
counter(out, "ipx_job_seconds_total", "Seconds the worker has spent on jobs, by kind.", &pairs(|v| v.1));
|
||||
let last = st.last_scan_done.load(Relaxed);
|
||||
if last > 0 {
|
||||
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
|
||||
}
|
||||
gauge(out, "ipx_http_requests_in_flight", "Web requests being answered.", &[("", st.http_in_flight.load(Relaxed) as f64)]);
|
||||
gauge(out, "ipx_scan_feeds_fetching", "Feeds a scan is fetching at once.", &[("", st.feeds_fetching.load(Relaxed) as f64)]);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_http_requests_in_flight",
|
||||
"Web requests being answered.",
|
||||
&[("", st.http_in_flight.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_scan_feeds_fetching",
|
||||
"Feeds a scan is fetching at once.",
|
||||
&[("", st.feeds_fetching.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_downloads_active", "Files downloading now.", &[("", st.downloads_active.load(Relaxed) as f64)]);
|
||||
counter(out, "ipx_download_bytes_total", "Bytes downloaded since the daemon started.", &[("", st.download_bytes.load(Relaxed) as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_download_bytes_total",
|
||||
"Bytes downloaded since the daemon started.",
|
||||
&[("", st.download_bytes.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_event_queue", "Live updates sent and not yet read by every listener.", &[("", event_queue as f64)]);
|
||||
counter(out, "ipx_events_missed_total", "Live updates dropped for a listener that fell behind.", &[("", st.events_missed.load(Relaxed) as f64)]);
|
||||
gauge(out, "ipx_deadair_backlog", "Files of shows someone has DeadAir on that it has not looked at, those already heard through included.", &[("", st.deadair_backlog.load(Relaxed) as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_events_missed_total",
|
||||
"Live updates dropped for a listener that fell behind.",
|
||||
&[("", st.events_missed.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_deadair_backlog",
|
||||
"Files of shows someone has DeadAir on that it has not looked at, those already heard through included.",
|
||||
&[("", st.deadair_backlog.load(Relaxed) as f64)],
|
||||
);
|
||||
let since = st.deadair_busy_since.load(Relaxed);
|
||||
gauge(out, "ipx_deadair_busy_seconds", "How long DeadAir has been on its file; 0 when on none.",
|
||||
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })]);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_deadair_busy_seconds",
|
||||
"How long DeadAir has been on its file; 0 when on none.",
|
||||
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })],
|
||||
);
|
||||
gauge(out, "ipx_feed_retries", "Feeds waiting to try a failed name lookup again.", &[("", retries as f64)]);
|
||||
gauge(out, "ipx_art_cache_bytes", "Artwork kept on disk, as of the last sweep.", &[("", st.art_cache_bytes.load(Relaxed) as f64)]);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_art_cache_bytes",
|
||||
"Artwork kept on disk, as of the last sweep.",
|
||||
&[("", st.art_cache_bytes.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_art_cache_limit_bytes", "The most artwork kept.", &[("", art_limit as f64)]);
|
||||
counter(out, "ipx_art_evicted_total", "Images thrown out to keep the artwork under its limit.", &[("", st.art_evicted.load(Relaxed) as f64)]);
|
||||
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
|
||||
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_art_evicted_total",
|
||||
"Images thrown out to keep the artwork under its limit.",
|
||||
&[("", st.art_evicted.load(Relaxed) as f64)],
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_art_requests_total",
|
||||
"Artwork asked for: served from disk, or fetched from the publisher.",
|
||||
&[
|
||||
("result=\"hit\"", st.art_hits.load(Relaxed) as f64),
|
||||
("result=\"miss\"", st.art_misses.load(Relaxed) as f64),
|
||||
],
|
||||
);
|
||||
// By person and whose key paid (#278). An account's name is often an email address: /metrics
|
||||
// is on the LAN only, as Prometheus is.
|
||||
let people = st.people.lock().unwrap().clone();
|
||||
let labels: Vec<String> =
|
||||
people.keys().map(|(who, key)| format!("user=\"{}\",key=\"{key}\"", label(who))).collect();
|
||||
let by = |n: usize| labels.iter().zip(people.values()).map(|(l, v)| (l.as_str(), v[n])).collect::<Vec<_>>();
|
||||
counter(
|
||||
out,
|
||||
"ipx_signals_judged_total",
|
||||
"Items Jev has judged for someone's Signals, by person and whose key paid.",
|
||||
&by(0),
|
||||
);
|
||||
counter(out, "ipx_signals_hits_total", "Signals an item was found to fit, by person and whose key paid.", &by(1));
|
||||
let tokens: Vec<String> =
|
||||
labels.iter().flat_map(|l| [format!("kind=\"input\",{l}"), format!("kind=\"output\",{l}")]).collect();
|
||||
let used: Vec<(&str, f64)> =
|
||||
tokens.iter().zip(people.values().flat_map(|v| [v[2], v[3]])).map(|(l, v)| (l.as_str(), v)).collect();
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_tokens_total",
|
||||
"Tokens TypeSafe counted for calls to Jev, what they cost, by person and whose key paid.",
|
||||
&used,
|
||||
);
|
||||
let last = st.signals_last_pass.load(Relaxed);
|
||||
if last > 0 {
|
||||
gauge(
|
||||
out,
|
||||
"ipx_signals_last_pass_age_seconds",
|
||||
"Seconds since the Signals judge last finished a pass.",
|
||||
&[("", (now - last) as f64)],
|
||||
);
|
||||
}
|
||||
let labels: Vec<String> = JEV_RESULTS.iter().map(|r| format!("result=\"{r}\"")).collect();
|
||||
let calls: Vec<(&str, f64)> =
|
||||
labels.iter().zip(&st.jev_calls).map(|(l, c)| (l.as_str(), c.load(Relaxed) as f64)).collect();
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_requests_total",
|
||||
"Calls to TypeSafe's Jev, Signals' and their icons', by how they ended.",
|
||||
&calls,
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_seconds_total",
|
||||
"Seconds spent on calls to Jev, their wait for a turn included.",
|
||||
&[("", st.jev_micros.load(Relaxed) as f64 / 1e6)],
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_paced_seconds_total",
|
||||
"Seconds calls to Jev waited for their turn under TypeSafe's rate limits.",
|
||||
&[("", st.jev_paced_micros.load(Relaxed) as f64 / 1e6)],
|
||||
);
|
||||
for (name, help, v) in [
|
||||
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
|
||||
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
|
||||
@@ -233,6 +422,11 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
|
||||
}
|
||||
}
|
||||
|
||||
/// A label's value as Prometheus's text format reads it.
|
||||
fn label(v: &str) -> String {
|
||||
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
|
||||
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
write(out, "counter", name, help, samples);
|
||||
@@ -278,15 +472,37 @@ mod tests {
|
||||
assert_eq!(T.downloads_active.load(Relaxed), 1);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 0, 0, 0);
|
||||
assert!(out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"), "{out}");
|
||||
assert!(
|
||||
out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
|
||||
assert_eq!(T.downloads_active.load(Relaxed), 0);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 3, 1, 10);
|
||||
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
|
||||
assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}");
|
||||
assert!(
|
||||
out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
|
||||
assert!(
|
||||
out.contains("ipx_jev_requests_total{result=\"key_refused\"} 0\n")
|
||||
&& !out.contains("ipx_signals_last_pass_age_seconds"),
|
||||
"{out}"
|
||||
);
|
||||
T.bill("sam@example.net", "server", [1.0, 1.0, 600.0, 50.0]);
|
||||
T.bill("sam@example.net", "server", [1.0, 0.0, 300.5, 25.0]);
|
||||
T.bill("a \"b\"", "own", [0.0, 0.0, 10.0, 1.0]);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 0, 0, 0);
|
||||
assert!(out.contains("ipx_signals_judged_total{user=\"sam@example.net\",key=\"server\"} 2\n"), "{out}");
|
||||
assert!(
|
||||
out.contains("ipx_jev_tokens_total{kind=\"input\",user=\"sam@example.net\",key=\"server\"} 900.5\n"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("ipx_jev_tokens_total{kind=\"output\",user=\"a \\\"b\\\"\",key=\"own\"} 1\n"), "{out}");
|
||||
// The library's counts are left out until the daemon has counted it.
|
||||
assert!(!out.contains("ipx_feeds "));
|
||||
}
|
||||
|
||||
@@ -39,19 +39,23 @@ pub fn pick(candidates: &[Candidate], total: u64, limit: u64) -> Vec<Candidate>
|
||||
|
||||
/// Files older than the cutoff. Flagged ones are already excluded by the query.
|
||||
pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|c| c.age_key > 0 && c.age_key < cutoff)
|
||||
.cloned()
|
||||
.collect()
|
||||
candidates.iter().filter(|c| c.age_key > 0 && c.age_key < cutoff).cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
let mut report = Report::default();
|
||||
|
||||
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
||||
// Someone may have deleted a file by hand; the row must stop claiming it exists. Not while
|
||||
// the folder itself is gone (the array stopped, shfs dropped): every file would look deleted
|
||||
// and the whole library be forgotten at once (#293).
|
||||
// Each step is a span of the reap's trace, so a slow one says which part was slow.
|
||||
for (id, path) in db.missing_files().instrument(step("missing_files")).await? {
|
||||
let missing = if cfg.general.download_dir.is_dir() {
|
||||
db.missing_files().instrument(step("missing_files")).await?
|
||||
} else {
|
||||
tracing::warn!(dir = %cfg.general.download_dir.display(), "the downloads folder is not there; left every file as it was");
|
||||
vec![]
|
||||
};
|
||||
for (id, path) in missing {
|
||||
if !dry_run {
|
||||
db.mark_reaped(id).await?;
|
||||
}
|
||||
@@ -79,11 +83,8 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
if cfg.general.max_total_gb > 0.0 {
|
||||
let limit = (cfg.general.max_total_gb * 1_073_741_824.0) as u64;
|
||||
// Re-read: the age sweep may already have freed enough.
|
||||
let remaining: Vec<Candidate> = candidates
|
||||
.iter()
|
||||
.filter(|c| !report.aged_out.iter().any(|a| a.id == c.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
let remaining: Vec<Candidate> =
|
||||
candidates.iter().filter(|c| !report.aged_out.iter().any(|a| a.id == c.id)).cloned().collect();
|
||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||
report.over_quota = pick(&remaining, total, limit);
|
||||
for c in &report.over_quota {
|
||||
@@ -99,10 +100,7 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
return Ok(c.bytes.max(0) as u64);
|
||||
}
|
||||
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
|
||||
let size = std::fs::metadata(&c.path)
|
||||
.ok()
|
||||
.filter(|m| m.is_file())
|
||||
.map_or(c.bytes.max(0) as u64, |m| m.len());
|
||||
let size = std::fs::metadata(&c.path).ok().filter(|m| m.is_file()).map_or(c.bytes.max(0) as u64, |m| m.len());
|
||||
// Missing is fine: the point is that it is gone.
|
||||
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
@@ -119,14 +117,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cand(id: i64, bytes: i64, age_key: i64, read: bool) -> Candidate {
|
||||
Candidate {
|
||||
id,
|
||||
url: format!("https://x/{id}"),
|
||||
path: format!("/tmp/{id}"),
|
||||
bytes,
|
||||
age_key,
|
||||
read,
|
||||
}
|
||||
Candidate { id, url: format!("https://x/{id}"), path: format!("/tmp/{id}"), bytes, age_key, read }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -183,7 +174,8 @@ mod tests {
|
||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||
@@ -212,13 +204,31 @@ mod tests {
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
|
||||
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
|
||||
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
|
||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_downloads_folder_that_is_gone_forgets_no_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1, 'f', 'a', 'u1', '/nowhere/ipx-test/a.mp3', 'done');",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = "/nowhere/ipx-test".into();
|
||||
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 0, "the folder is gone, not the file");
|
||||
|
||||
cfg.general.download_dir = std::env::temp_dir();
|
||||
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is not");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
@@ -233,7 +243,8 @@ mod tests {
|
||||
('f', 'recent', 900);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||
|
||||
@@ -5,23 +5,40 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use futures_util::StreamExt as _;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
use crate::Ctx;
|
||||
use crate::db::{Ask, Unjudged};
|
||||
use crate::db::{Ask, Unjudged, step};
|
||||
use crate::feed;
|
||||
use crate::metrics::STATS;
|
||||
|
||||
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
|
||||
/// long the queue is held before people who got a key later are looked at.
|
||||
const BATCH: i64 = 50;
|
||||
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
|
||||
const IDLE: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
/// Calls to Jev in flight at once (#275). One at a time, about 150 ms each, a new Signal's month
|
||||
/// of items took twenty minutes. Enough for `MAX_RATE` at that pace; the pace is `pace`'s to keep.
|
||||
const IN_FLIGHT: usize = 10;
|
||||
/// TypeSafe's default limits are 80 requests and 100,000 input tokens a second (#277). Calls are
|
||||
/// paced under both, with room left for the icon picks and key checks that share them.
|
||||
const MAX_RATE: f64 = 60.0;
|
||||
const MAX_TOKENS: f64 = 80_000.0;
|
||||
/// When the next call may start, for every call ipx makes to Jev.
|
||||
static NEXT_CALL: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
|
||||
/// How sure Jev has to be that an item is about the keywords.
|
||||
const THRESHOLD: f64 = 0.5;
|
||||
/// Of an item's text, which is a podcast's whole show notes at times.
|
||||
const TEXT_CHARS: usize = 2000;
|
||||
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
|
||||
/// items and six Signals, 1000 changed none of 330 verdicts and saved an eighth of the input
|
||||
/// tokens; 500 changed one, an Apple podcast's episode no longer about Apple.
|
||||
const TEXT_CHARS: usize = 1000;
|
||||
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
@@ -29,6 +46,7 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
_ = stop.changed() => return,
|
||||
done = pass(&ctx) => done,
|
||||
};
|
||||
crate::metrics::STATS.signals_last_pass.store(crate::db::now(), Relaxed);
|
||||
match done {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
@@ -42,62 +60,137 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key every Signal is judged on, when an admin has chosen that the server pays for Jev
|
||||
/// (#271) and the server has a key; None leaves each person to their own.
|
||||
pub fn server_key(ctx: &Ctx) -> Option<String> {
|
||||
if !ctx.cfg().general.signals_on_server {
|
||||
return None;
|
||||
}
|
||||
typesafe_key()
|
||||
}
|
||||
|
||||
/// The server's own TypeSafe key, `TYPESAFE_KEY`; set but empty is none, as the browser suite
|
||||
/// sets it so the test daemon never spends a real one.
|
||||
pub fn typesafe_key() -> Option<String> {
|
||||
std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty())
|
||||
}
|
||||
|
||||
/// One batch. True when there may be more waiting.
|
||||
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||
let skip: Vec<i64> = ctx.signals_refused.lock().unwrap().iter().copied().collect();
|
||||
let items = ctx.db.unjudged(&skip, BATCH).await?;
|
||||
let server = server_key(ctx);
|
||||
// People's refused keys are not what is asked with when the server's is.
|
||||
let skip: Vec<i64> =
|
||||
if server.is_some() { Vec::new() } else { ctx.signals_refused.lock().unwrap().iter().copied().collect() };
|
||||
let days = ctx.cfg().general.signal_days;
|
||||
let items = ctx.db.unjudged(&skip, BATCH, server.is_some(), days).await?;
|
||||
if items.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let span = tracing::info_span!("signals", entries = items.len());
|
||||
let span = tracing::info_span!(
|
||||
"signals",
|
||||
entries = items.len(),
|
||||
calls = tracing::field::Empty,
|
||||
judged = tracing::field::Empty,
|
||||
hits = tracing::field::Empty
|
||||
);
|
||||
async {
|
||||
let mut refused = HashSet::new();
|
||||
let groups = calls(&items);
|
||||
let paid = if server.is_some() { "server" } else { "own" };
|
||||
tracing::Span::current().record("calls", groups.len());
|
||||
let refused = std::sync::Mutex::new(HashSet::new());
|
||||
let stopped = AtomicBool::new(false);
|
||||
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
|
||||
let mut stopped = false;
|
||||
for item in &items {
|
||||
if refused.contains(&item.user_id) {
|
||||
continue;
|
||||
}
|
||||
match judge(ctx, item).await {
|
||||
Ok(verdicts) => {
|
||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||
if hits > 0 {
|
||||
*hits_for.entry(item.user_id).or_default() += hits;
|
||||
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||
let mut judged = 0;
|
||||
// Each call starts only when a place is free, so one that sees a key refused or the pass
|
||||
// stopped is not sent; those already in flight are let finish, and their answers kept.
|
||||
// By index: a closure taking a reference here is not Send for every lifetime, which
|
||||
// tokio::spawn asks of the judge's future.
|
||||
let mut answers = futures_util::stream::iter(0..groups.len())
|
||||
.map(|n| {
|
||||
let (group, refused, stopped, server) = (&groups[n], &refused, &stopped, &server);
|
||||
async move {
|
||||
let first = group[0];
|
||||
if stopped.load(Relaxed) || refused.lock().unwrap().contains(&first.user_id) {
|
||||
return None;
|
||||
}
|
||||
let key = server.as_deref().or(first.key.as_deref())?;
|
||||
Some((group, judge(ctx, group, key, paid).await))
|
||||
}
|
||||
})
|
||||
.buffer_unordered(IN_FLIGHT);
|
||||
while let Some(answer) = answers.next().await {
|
||||
let Some((group, answer)) = answer else { continue };
|
||||
let first = group[0];
|
||||
match answer {
|
||||
Ok(verdicts) => {
|
||||
for (item, verdicts) in group.iter().zip(verdicts) {
|
||||
judged += 1;
|
||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||
STATS.bill(&item.user_name, paid, [1.0, hits as f64, 0.0, 0.0]);
|
||||
if hits > 0 {
|
||||
*hits_for.entry(item.user_id).or_default() += hits;
|
||||
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||
}
|
||||
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(step("record_verdicts")).await?;
|
||||
}
|
||||
}
|
||||
// The server's key refused is everyone's: the queue waits for the next pass.
|
||||
Err(e) if refuses_key(&e) && server.is_some() => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
|
||||
}
|
||||
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).await?;
|
||||
}
|
||||
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
|
||||
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
|
||||
Err(e) if refuses_key(&e) => {
|
||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||
refused.insert(item.user_id);
|
||||
if refused.lock().unwrap().insert(first.user_id) {
|
||||
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||
}
|
||||
}
|
||||
// TypeSafe says the request itself is wrong, so every other item's would be too:
|
||||
// the rest wait for the next pass rather than each failing the same way (#257).
|
||||
Err(e) if malformed(&e) => {
|
||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||
stopped = true;
|
||||
break;
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||
}
|
||||
}
|
||||
// Over TypeSafe's limits: what is in flight finishes, nothing more starts, and
|
||||
// the next pass waits for `back_off`.
|
||||
Err(e) if rate_limited(&e) => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "TypeSafe asked Signals to slow down");
|
||||
}
|
||||
}
|
||||
// Left unjudged, so it is asked again next pass.
|
||||
Err(e) => tracing::warn!(user = item.user_id, feed = %item.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
||||
Err(e) => tracing::warn!(user = first.user_id, feed = %first.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
||||
}
|
||||
}
|
||||
drop(answers);
|
||||
let span = tracing::Span::current();
|
||||
span.record("judged", judged);
|
||||
span.record("hits", hits_for.values().sum::<usize>());
|
||||
// Once a pass, not once an item: a page fetches its counts again on each.
|
||||
for (user, hits) in hits_for {
|
||||
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
||||
}
|
||||
let refused = refused.into_inner().unwrap();
|
||||
let some_refused = !refused.is_empty();
|
||||
ctx.signals_refused.lock().unwrap().extend(refused);
|
||||
// A pass that only failed would otherwise go straight round again on the same items, and
|
||||
// one TypeSafe refused as malformed would ask again at once, however full.
|
||||
anyhow::Ok(!stopped && (full(&items) || some_refused))
|
||||
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
|
||||
/// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
|
||||
/// then one person's description was in the question that decided another's verdicts (#343).
|
||||
fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
|
||||
items.iter().map(|item| vec![item]).collect()
|
||||
}
|
||||
|
||||
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||
/// alone said only 422 (#257).
|
||||
#[derive(Debug)]
|
||||
@@ -125,51 +218,90 @@ fn full(items: &[Unjudged]) -> bool {
|
||||
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
||||
}
|
||||
|
||||
fn refuses_key(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(401 | 402 | 403))
|
||||
pub fn refuses_key(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(401..=403))
|
||||
}
|
||||
|
||||
fn rate_limited(e: &anyhow::Error) -> bool {
|
||||
status(e) == Some(429)
|
||||
}
|
||||
|
||||
/// A call's turn: it starts at `next` or now, whichever is later, and holds the line for its
|
||||
/// share of a second under both limits. Gives when it starts and when the one after may.
|
||||
fn slot(
|
||||
next: tokio::time::Instant,
|
||||
now: tokio::time::Instant,
|
||||
tokens: f64,
|
||||
) -> (tokio::time::Instant, tokio::time::Instant) {
|
||||
let start = next.max(now);
|
||||
(start, start + std::time::Duration::from_secs_f64((1.0 / MAX_RATE).max(tokens / MAX_TOKENS)))
|
||||
}
|
||||
|
||||
/// Waits for this call's turn. Its tokens are guessed from the request, a token to three bytes
|
||||
/// of JSON, on the high side: TypeSafe says the true count only in the answer.
|
||||
async fn pace(body: &serde_json::Value) {
|
||||
let tokens = body.to_string().len() as f64 / 3.0;
|
||||
let start = {
|
||||
let mut next = NEXT_CALL.lock().await;
|
||||
let now = tokio::time::Instant::now();
|
||||
let (start, after) = slot(next.unwrap_or(now), now, tokens);
|
||||
*next = Some(after);
|
||||
start
|
||||
};
|
||||
let waited = start.saturating_duration_since(tokio::time::Instant::now());
|
||||
tokio::time::sleep_until(start).await;
|
||||
// How long the limits held this call back: near nothing unless ipx is near them (#277).
|
||||
STATS.jev_paced_micros.fetch_add(waited.as_micros() as u64, Relaxed);
|
||||
tracing::Span::current().record("paced_ms", waited.as_millis() as u64);
|
||||
}
|
||||
|
||||
/// TypeSafe said too many: nobody calls again until it says, or a second.
|
||||
async fn back_off(retry_after: Option<u64>) {
|
||||
let until = tokio::time::Instant::now() + std::time::Duration::from_secs(retry_after.unwrap_or(1).min(60));
|
||||
let mut next = NEXT_CALL.lock().await;
|
||||
*next = Some(next.map_or(until, |n| n.max(until)));
|
||||
}
|
||||
|
||||
fn malformed(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(400 | 422))
|
||||
}
|
||||
|
||||
async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
|
||||
async fn judge(ctx: &Ctx, group: &[&Unjudged], key: &str, paid: &'static str) -> Result<Vec<Vec<(i64, bool)>>> {
|
||||
let asks: Vec<&Ask> = group.iter().flat_map(|i| &i.signals).collect();
|
||||
let span = tracing::info_span!(
|
||||
"jev",
|
||||
user = item.user_id,
|
||||
signal_count = item.signals.len(),
|
||||
user = group[0].user_id,
|
||||
people = group.len(),
|
||||
signal_count = asks.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
let answer: serde_json::Value = async {
|
||||
let r = ctx.client.post(url).bearer_auth(&item.key).json(&request(item)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
tracing::Span::current().record("error", body.as_str());
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
anyhow::Ok(r.json().await?)
|
||||
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
|
||||
// A shared call is billed by each person's share of its questions, which are most of it.
|
||||
let [input, output] = usage(&answer);
|
||||
for item in group {
|
||||
let share = item.signals.len() as f64 / asks.len() as f64;
|
||||
STATS.bill(&item.user_name, paid, [0.0, 0.0, input * share, output * share]);
|
||||
}
|
||||
.instrument(span)
|
||||
.await?;
|
||||
// A question Jev left unanswered stays unjudged and is asked again.
|
||||
Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect())
|
||||
Ok(group.iter().map(|i| i.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()).collect())
|
||||
}
|
||||
|
||||
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
|
||||
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
|
||||
fn request(item: &Unjudged) -> serde_json::Value {
|
||||
fn request(item: &Unjudged, asks: &[&Ask]) -> serde_json::Value {
|
||||
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
|
||||
let state = format!(
|
||||
"Feed: {}\nTitle: {}\n\n{text}",
|
||||
item.feed_title.as_deref().unwrap_or(""),
|
||||
item.title.as_deref().unwrap_or("")
|
||||
);
|
||||
let questions: serde_json::Map<String, serde_json::Value> =
|
||||
item.signals.iter().map(|a| (key(a.id), question(a))).collect();
|
||||
let questions: serde_json::Map<String, serde_json::Value> = asks.iter().map(|a| (key(a.id), question(a))).collect();
|
||||
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
||||
}
|
||||
|
||||
@@ -183,7 +315,11 @@ fn key(id: i64) -> String {
|
||||
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
|
||||
/// mean: a sentence on its own was refused with a 422 (#257).
|
||||
fn question(a: &Ask) -> serde_json::Value {
|
||||
let mut said = format!("The reader follows a subject they call \"{}\", described by these words: {}.", a.name, a.keywords.join(", "));
|
||||
let mut said = format!(
|
||||
"The reader follows a subject they call \"{}\", described by these words: {}.",
|
||||
a.name,
|
||||
a.keywords.join(", ")
|
||||
);
|
||||
if let Some(d) = &a.description {
|
||||
said.push_str(&format!(" In their own words: {d}"));
|
||||
}
|
||||
@@ -206,25 +342,124 @@ fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
||||
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||
/// server's page doing it, once a Signal.
|
||||
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let span = tracing::info_span!("jev_icon", candidates = candidates.len(), "http.response.status_code" = tracing::field::Empty);
|
||||
async {
|
||||
let r = ctx.client.post(url).bearer_auth(key).json(&icon_request(name, words, description, candidates)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
let answer: serde_json::Value = r.json().await?;
|
||||
pub async fn pick_icon(
|
||||
ctx: &Ctx,
|
||||
who: &str,
|
||||
key: &str,
|
||||
name: &str,
|
||||
words: &[String],
|
||||
description: Option<&str>,
|
||||
candidates: &[(String, String)],
|
||||
) -> Result<String> {
|
||||
let span = tracing::info_span!(
|
||||
"jev_icon",
|
||||
candidates = candidates.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
called(span, async {
|
||||
let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?;
|
||||
let [input, output] = usage(&answer);
|
||||
STATS.bill(who, "server", [0.0, 0.0, input, output]);
|
||||
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||
}
|
||||
.instrument(span)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn icon_request(name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> serde_json::Value {
|
||||
/// Whether TypeSafe takes `key`, by one short question, before it is saved (#271): a key it
|
||||
/// refuses would sit saved with nothing ever picked out, and nobody told why.
|
||||
pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
|
||||
let span = tracing::info_span!(
|
||||
"jev_key_check",
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": "A reader is saving their key.",
|
||||
"questions": { "ok": { "type": "noul", "instructions": "Is this a key check?",
|
||||
"criteria": { "true": "It is.", "false": "It is not." } } },
|
||||
});
|
||||
let answer = called(span, ask(ctx, key, &body)).await?;
|
||||
let [input, output] = usage(&answer);
|
||||
STATS.bill(who, "own", [0.0, 0.0, input, output]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries
|
||||
/// this trace's `traceparent`, as every call out of ipx does.
|
||||
async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT);
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
pace(body).await;
|
||||
let r = req.send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if status == 429 {
|
||||
back_off(r.headers().get("retry-after").and_then(|v| v.to_str().ok()?.parse().ok())).await;
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
let answer: serde_json::Value = r.json().await?;
|
||||
// What a call cost, which TypeSafe says and nothing else would (#275); billed by the caller,
|
||||
// who knows whose it was (#278).
|
||||
for kind in ["input_tokens", "output_tokens"] {
|
||||
if let Some(t) = answer["usage"][kind].as_u64() {
|
||||
tracing::Span::current().record(kind, t);
|
||||
}
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Input and output tokens, as an answer counts them; 0 when it does not.
|
||||
fn usage(answer: &serde_json::Value) -> [f64; 2] {
|
||||
["input_tokens", "output_tokens"].map(|k| answer["usage"][k].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
|
||||
/// failed, which a field named `error` alone does not: Tempo's failed traces left Jev's out.
|
||||
async fn called<T>(span: tracing::Span, call: impl std::future::Future<Output = Result<T>>) -> Result<T> {
|
||||
let started = std::time::Instant::now();
|
||||
let r = call.instrument(span.clone()).await;
|
||||
STATS.jev_micros.fetch_add(started.elapsed().as_micros() as u64, Relaxed);
|
||||
STATS.jev_calls[outcome(&r)].fetch_add(1, Relaxed);
|
||||
if let Err(e) = &r {
|
||||
span.record("error", format!("{e:#}").as_str());
|
||||
span.record("otel.status_code", "ERROR");
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Which of `metrics::JEV_RESULTS` a call's result is.
|
||||
fn outcome<T>(r: &Result<T>) -> usize {
|
||||
match r {
|
||||
Ok(_) => 0,
|
||||
Err(e) if refuses_key(e) => 1,
|
||||
Err(e) if malformed(e) => 2,
|
||||
Err(e) if rate_limited(e) => 3,
|
||||
Err(_) => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_request(
|
||||
name: &str,
|
||||
words: &[String],
|
||||
description: Option<&str>,
|
||||
candidates: &[(String, String)],
|
||||
) -> serde_json::Value {
|
||||
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||
if let Some(d) = description {
|
||||
state.push_str(&format!("\nIn their words: {d}"));
|
||||
@@ -307,7 +542,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_icon_is_one_jev_was_offered() {
|
||||
let offered = vec![("solid/robot".to_string(), "Robot: ai, bot".to_string()), ("solid/laptop-code".to_string(), "Laptop Code".to_string())];
|
||||
let offered = vec![
|
||||
("solid/robot".to_string(), "Robot: ai, bot".to_string()),
|
||||
("solid/laptop-code".to_string(), "Laptop Code".to_string()),
|
||||
];
|
||||
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||
@@ -327,13 +565,73 @@ mod tests {
|
||||
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_is_counted_by_how_it_ended() {
|
||||
let refused = |status| -> Result<()> { Err(Refused { status, body: String::new() }.into()) };
|
||||
let named = |r: Result<()>| crate::metrics::JEV_RESULTS[outcome(&r)];
|
||||
assert_eq!(named(Ok(())), "ok");
|
||||
assert_eq!(named(refused(402)), "key_refused");
|
||||
assert_eq!(named(refused(422)), "malformed");
|
||||
assert_eq!(named(refused(503)), "error");
|
||||
assert_eq!(named(refused(429)), "rate_limited");
|
||||
assert_eq!(named(Err(anyhow::anyhow!("timed out"))), "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_persons_signals_never_share_a_call_with_anothers() {
|
||||
let row = |user_id, guid: &str| Unjudged {
|
||||
user_id,
|
||||
user_name: format!("u{user_id}"),
|
||||
key: None,
|
||||
feed_id: "f".into(),
|
||||
guid: guid.into(),
|
||||
feed_title: None,
|
||||
title: None,
|
||||
description: None,
|
||||
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
|
||||
};
|
||||
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
|
||||
let shape: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
|
||||
assert_eq!(shape, [vec![1], vec![2], vec![1]], "x is asked for each of them apart (#343)");
|
||||
let mine: Vec<&Ask> = calls(&items)[0].iter().flat_map(|i| &i.signals).collect();
|
||||
let r = request(&items[0], &mine);
|
||||
assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_are_paced_under_both_of_typesafes_limits() {
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let ms = |a: tokio::time::Instant| (a - t0).as_secs_f64() * 1000.0;
|
||||
// A small call holds the line for a 60th of a second, under 80 requests a second.
|
||||
let (start, next) = slot(t0, t0, 1000.0);
|
||||
assert_eq!((ms(start), ms(next).round()), (0.0, 17.0));
|
||||
// One of 8,000 tokens, a tenth of a second, under 100,000 tokens a second.
|
||||
let (start, next) = slot(next, t0, 8000.0);
|
||||
assert_eq!((ms(start).round(), ms(next).round()), (17.0, 117.0));
|
||||
// After a quiet spell, a call starts at once.
|
||||
let later = t0 + std::time::Duration::from_secs(5);
|
||||
assert_eq!(slot(next, later, 1000.0).0, later);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||
let item = |n: usize| Unjudged {
|
||||
user_id: 1, key: "k".into(), feed_id: "f".into(), guid: "g".into(), feed_title: None, title: None, description: None,
|
||||
signals: (0..n as i64).map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None }).collect(),
|
||||
user_id: 1,
|
||||
user_name: "a".into(),
|
||||
key: Some("k".into()),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: None,
|
||||
title: None,
|
||||
description: None,
|
||||
signals: (0..n as i64)
|
||||
.map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None })
|
||||
.collect(),
|
||||
};
|
||||
assert!(full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()), "25 items of two Signals each is the 50 asked for");
|
||||
assert!(
|
||||
full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()),
|
||||
"25 items of two Signals each is the 50 asked for"
|
||||
);
|
||||
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
|
||||
}
|
||||
|
||||
@@ -341,7 +639,8 @@ mod tests {
|
||||
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||
let item = Unjudged {
|
||||
user_id: 1,
|
||||
key: "k".into(),
|
||||
user_name: "a".into(),
|
||||
key: Some("k".into()),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: Some("Pit Lane".into()),
|
||||
@@ -349,17 +648,33 @@ mod tests {
|
||||
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
||||
signals: vec![
|
||||
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
|
||||
Ask { id: 4, name: "Food".into(), keywords: vec!["cooking".into()], description: Some("Recipes, not restaurants.".into()) },
|
||||
Ask {
|
||||
id: 4,
|
||||
name: "Food".into(),
|
||||
keywords: vec!["cooking".into()],
|
||||
description: Some("Recipes, not restaurants.".into()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let r = request(&item);
|
||||
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
|
||||
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
||||
let q = &r["questions"]["signal_3"];
|
||||
assert_eq!(q["type"], "noul");
|
||||
let says = q["instructions"].as_str().unwrap();
|
||||
assert!(says.contains("\"F1\"") && says.contains("formula 1, f1"), "one subject, by its name and its words: {says}");
|
||||
assert!(
|
||||
says.contains("\"F1\"") && says.contains("formula 1, f1"),
|
||||
"one subject, by its name and its words: {says}"
|
||||
);
|
||||
assert!(!says.contains("own words"), "no sentence, none said");
|
||||
assert!(q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(), "TypeSafe takes an object, not a sentence");
|
||||
assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("In their own words: Recipes, not restaurants."));
|
||||
assert!(
|
||||
q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(),
|
||||
"TypeSafe takes an object, not a sentence"
|
||||
);
|
||||
assert!(
|
||||
r["questions"]["signal_4"]["instructions"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("In their own words: Recipes, not restaurants.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@ impl Torrents {
|
||||
let listen_addr: SocketAddr = format!("0.0.0.0:{lo}").parse()?;
|
||||
|
||||
let opts = SessionOptions {
|
||||
listen: Some(librqbit::ListenerOptions {
|
||||
listen_addr,
|
||||
..Default::default()
|
||||
}),
|
||||
listen: Some(librqbit::ListenerOptions { listen_addr, ..Default::default() }),
|
||||
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -42,13 +39,15 @@ impl Torrents {
|
||||
}
|
||||
|
||||
/// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`,
|
||||
/// seeds it up to the configured limit, then stops.
|
||||
/// seeds it up to the configured limit, then stops. `dest_dir` is the torrent's own
|
||||
/// folder (`own_folder`), and it is the path returned: a file of several or one.
|
||||
///
|
||||
/// Downloading in place rather than into a staging dir is deliberate: seeding serves
|
||||
/// the files it downloaded, so moving them first would break it.
|
||||
pub async fn fetch(
|
||||
&self,
|
||||
cfg: &Config,
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
dest_dir: &Path,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
@@ -56,6 +55,15 @@ impl Torrents {
|
||||
tokio::fs::create_dir_all(dest_dir).await?;
|
||||
|
||||
let limit = stall_limit(cfg);
|
||||
let source = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
// Under the stall budget as well: a body sent a byte a minute stays inside the cap.
|
||||
let file = tokio::time::timeout(limit, torrent_file(client, url))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("no .torrent after {} minutes, gave up", limit.as_secs() / 60))??;
|
||||
AddTorrent::from_bytes(file)
|
||||
} else {
|
||||
AddTorrent::from_url(url)
|
||||
};
|
||||
|
||||
// Resolving a magnet's metadata happens inside add_torrent, and against a dead
|
||||
// swarm it never returns. The stall budget has to cover this phase too, or a
|
||||
@@ -63,7 +71,7 @@ impl Torrents {
|
||||
let added = tokio::time::timeout(
|
||||
limit,
|
||||
self.session.add_torrent(
|
||||
AddTorrent::from_url(url),
|
||||
source,
|
||||
Some(AddTorrentOptions {
|
||||
output_folder: Some(dest_dir.to_string_lossy().into_owned()),
|
||||
overwrite: true,
|
||||
@@ -72,12 +80,7 @@ impl Torrents {
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"no metadata after {} minutes, gave up",
|
||||
limit.as_secs() / 60
|
||||
)
|
||||
})?
|
||||
.map_err(|_| anyhow::anyhow!("no metadata after {} minutes, gave up", limit.as_secs() / 60))?
|
||||
.context("adding the torrent")?;
|
||||
|
||||
let (id, handle) = match added {
|
||||
@@ -111,15 +114,14 @@ impl Torrents {
|
||||
}
|
||||
}
|
||||
|
||||
let name = handle.name().unwrap_or_else(|| "torrent".to_owned());
|
||||
let path = handle.output_folder().join(&name);
|
||||
// The torrent's own name is the publisher's to choose, and librqbit checks it only when it
|
||||
// picks the folder itself: joined on, "/config" or ".." pointed Delete, the reaper and
|
||||
// /media at anything the daemon can reach (#318). The folder is ours, so it is the path.
|
||||
let path = dest_dir.to_owned();
|
||||
let size = handle.stats().total_bytes;
|
||||
|
||||
self.seed(cfg, &handle).await;
|
||||
self.session
|
||||
.delete(id.into(), false)
|
||||
.await
|
||||
.context("releasing the torrent")?;
|
||||
self.session.delete(id.into(), false).await.context("releasing the torrent")?;
|
||||
|
||||
Ok((path, size))
|
||||
}
|
||||
@@ -145,6 +147,30 @@ impl Torrents {
|
||||
}
|
||||
}
|
||||
|
||||
/// A .torrent file is metadata: a few hundred KB even for a large torrent.
|
||||
const MAX_TORRENT_FILE: usize = 10 << 20;
|
||||
|
||||
/// Fetches a .torrent file, up to `MAX_TORRENT_FILE`. Handed the URL, librqbit read the whole body
|
||||
/// into memory with no limit and no timeout, so a feed could serve one without end (#330).
|
||||
async fn torrent_file(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
|
||||
let mut resp = client.get(url).send().await.context("fetching the .torrent")?.error_for_status()?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(c) = resp.chunk().await.context("reading the .torrent")? {
|
||||
out.extend_from_slice(&c);
|
||||
if out.len() > MAX_TORRENT_FILE {
|
||||
bail!("the .torrent is larger than {} MB, too large to be one", MAX_TORRENT_FILE >> 20);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The folder an enclosure's torrent downloads into, one per enclosure inside the feed's folder.
|
||||
/// With `overwrite` on, a torrent written straight into the feed's folder could replace another
|
||||
/// enclosure's files of the same name, another feed's too when they share a folder (#331).
|
||||
pub fn own_folder(feed_dir: &Path, enclosure: i64) -> PathBuf {
|
||||
feed_dir.join(format!("torrent-{enclosure}"))
|
||||
}
|
||||
|
||||
/// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero.
|
||||
pub fn ratio(uploaded: u64, total: u64) -> f64 {
|
||||
if total == 0 {
|
||||
@@ -165,6 +191,32 @@ mod tests {
|
||||
assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_endless_torrent_file_stops_at_the_cap() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let _ = sock.read(&mut [0u8; 2048]).await;
|
||||
// No length, no end: a body that goes on until the reader stops.
|
||||
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n").await;
|
||||
let chunk = vec![b'd'; 64 << 10];
|
||||
while sock.write_all(&chunk).await.is_ok() {}
|
||||
});
|
||||
let err = torrent_file(&reqwest::Client::new(), &format!("http://{addr}/ep.torrent")).await.unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_folder_is_one_folder_inside_the_feeds() {
|
||||
let feed = Path::new("/downloads/Show");
|
||||
let dir = own_folder(feed, 42);
|
||||
assert_eq!(dir.parent(), Some(feed));
|
||||
assert!(matches!(dir.components().next_back(), Some(std::path::Component::Normal(_))));
|
||||
assert_ne!(own_folder(feed, 43), dir, "two enclosures never share a folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_limit_is_never_zero() {
|
||||
let mut cfg = Config::default();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
34
deny.toml
Normal file
34
deny.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
# cargo deny check: known vulnerabilities, licences and where crates come from. Run before a push
|
||||
# (CLAUDE.md, Tests). It reads Cargo.lock and the RustSec database, and builds nothing.
|
||||
|
||||
[graph]
|
||||
# Production runs on Linux; Windows and macOS-only crates are not ipx's concern.
|
||||
targets = ["x86_64-unknown-linux-gnu"]
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# ringbuf 0.4's double free needs an element whose Drop panics; librqbit-utp's buffer holds
|
||||
# only u8, which has no Drop. Its fix is ringbuf 0.5, which librqbit 9.0.1 cannot take (#281).
|
||||
{ id = "RUSTSEC-2026-0293", reason = "librqbit-utp's ring buffer holds u8: no Drop to panic" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
# Permissive licences only; anything else is a question for the owner before it comes in.
|
||||
allow = [
|
||||
"MIT", "MIT-0", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause",
|
||||
"ISC", "Zlib", "Unlicense", "0BSD", "CC0-1.0", "BSL-1.0", "Unicode-3.0", "MPL-2.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
# ipx's own crates have no licence field and are not published.
|
||||
private = { ignore = true }
|
||||
|
||||
[bans]
|
||||
# Two versions of one crate (syn 1 and 2, and 25 more) only cost build time, and a warning for
|
||||
# each on every run would be read by nobody.
|
||||
multiple-versions = "allow"
|
||||
wildcards = "deny"
|
||||
allow-wildcard-paths = true
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
@@ -1,6 +1,7 @@
|
||||
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both
|
||||
# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then
|
||||
# `docker compose up -d`, and sign in at http://<host>:8099 as admin / ipodderx.
|
||||
# `docker compose up -d`, and sign in at http://<host>:8099 as admin, with the password the first
|
||||
# start writes to the config volume: `docker exec ipx cat /config/admin-password`.
|
||||
# docs/configuration.md has the rest, Postgres included.
|
||||
services:
|
||||
ipx:
|
||||
|
||||
@@ -31,13 +31,14 @@ Passwords are read from **stdin**, so they miss the shell history and any `ps` l
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user add ray # local account
|
||||
ipx user add ray@example.com --no-password # signs in through the proxy only
|
||||
echo -n 'a good password' | ipx user passwd admin # change a password
|
||||
echo -n 'a good password' | ipx user passwd admin # change a password; signs the account out everywhere
|
||||
ipx user list # who exists, and how each signs in
|
||||
ipx user rm sam # account, subscriptions and read state
|
||||
```
|
||||
|
||||
The first account created is an admin; later ones are ordinary users. A database with no accounts
|
||||
at all gets **admin / ipodderx** on the next daemon start, announced in the log — change it.
|
||||
at all gets **admin** on the next daemon start, with a random password in `admin-password` beside
|
||||
config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it.
|
||||
|
||||
To avoid even the command line, read it interactively:
|
||||
|
||||
|
||||
@@ -46,10 +46,11 @@ max_age_days = 30 # 0 = keep forever
|
||||
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
||||
media_types = ["audio", "video"]
|
||||
art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||
fetch_internal = [] # internal addresses fetches may reach, e.g. ["192.168.1.0/24"]
|
||||
```
|
||||
|
||||
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
|
||||
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||
database as described above; `download_dir`, `socket`, `organize` and `fetch_internal` stay in config.toml.
|
||||
|
||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. Without a schedule of its own,
|
||||
@@ -77,6 +78,12 @@ database as described above; `download_dir`, `socket` and `organize` stay in con
|
||||
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
||||
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
||||
without this the disk fills with artwork. Empty takes everything.
|
||||
* **`fetch_internal`** — internal addresses and networks that feeds, episodes, artwork and
|
||||
`.torrent` files may still be fetched from, as `["192.168.1.20", "10.0.0.0/8"]`. Anything off
|
||||
the public internet (loopback, the LAN, Docker's networks, link-local) is refused otherwise,
|
||||
redirects included, because a feed's publisher or anyone signed in chooses what is fetched. List
|
||||
addresses, not names: a feed on `nas.lan` needs `nas.lan`'s address here. The directory is not
|
||||
affected. Empty by default; read at start.
|
||||
|
||||
## `[torrent]`
|
||||
|
||||
@@ -153,6 +160,9 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
|
||||
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
||||
and are the same for everyone. See [users.md](users.md).
|
||||
|
||||
`username` and its password go to the feed's own host alone: not to episodes on another host, nor to
|
||||
the feeds an OPML lists on other hosts, which would hand the password to whoever runs them.
|
||||
|
||||
**The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
|
||||
up in Admin, Directory: its address, this server's token and whether to share. They are server
|
||||
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
|
||||
@@ -165,13 +175,18 @@ subscribes to is checked once a day. One that is dead (failing for 30 days) or q
|
||||
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
|
||||
|
||||
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. So
|
||||
does Keep, the plus on a show's page, for whoever turns it on (`kept` on their subscription, #280): when
|
||||
the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue
|
||||
entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who
|
||||
had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `IPX_CONFIG` | Config file path |
|
||||
| `IPX_ADMIN_PASSWORD` | The first admin's password, for a database with no accounts; random, in `admin-password` beside config.toml, without it |
|
||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
||||
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||
@@ -191,6 +206,7 @@ The page's container reads only its environment (`web/Caddyfile`):
|
||||
| `WEB_BIND` | Where to listen; `:8080` by default |
|
||||
| `WEB_ROOT` | The built page; `/srv` in the image |
|
||||
| `TUNNEL_PEER` | The one address whose `Cf-Access-*` headers are passed on to ipx: where cloudflared's requests come from. Unset passes none. See [sso.md](sso.md) |
|
||||
| `TRUSTED_HEADER` | ipx's `[web] trusted_header`, when it is not Cloudflare's: it is passed on from `TUNNEL_PEER` alone too, and dropped from anyone else. Set it to the same name, or anyone could sign in as anyone |
|
||||
| `WEB_TRACING` | `on` sends a span for each request to the collector the `OTEL_*` variables name (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, `OTEL_SERVICE_NAME`), continuing the page's trace into ipx. Off by default |
|
||||
|
||||
ipx-directory's settings are in [directory.md](directory.md).
|
||||
|
||||
@@ -83,6 +83,9 @@ bind = "0.0.0.0:8100"
|
||||
metrics_bind = "0.0.0.0:9188"
|
||||
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
|
||||
apple_country = "ca"
|
||||
# Internal addresses and networks feeds may be fetched from, as ["10.0.0.5", "192.168.1.0/24"].
|
||||
# Empty, or left out: nothing off the public internet is fetched (#329).
|
||||
fetch_internal = []
|
||||
# The environment variable holding the database URL: postgres://... or sqlite://...
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||
|
||||
|
||||
105
docs/signals.md
105
docs/signals.md
@@ -6,13 +6,21 @@ mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and p
|
||||
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
|
||||
own feed. The rest stays noise, where it was.
|
||||
|
||||
Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone
|
||||
without one. Jev is asked once for each item, on that person's key, with one question for each of
|
||||
their Signals that has not heard about the item yet.
|
||||
Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for
|
||||
everyone, on this server's TypeSafe key** (`signals_on_server`):
|
||||
|
||||
- **On**, with the server's key in `TYPESAFE_KEY`: everyone has Signals, with nothing to set up.
|
||||
Every item is asked about on the server's key, and keys people saved are kept but not used.
|
||||
- **Off**, the default, or on with no `TYPESAFE_KEY`: each person turns Signals on in Settings
|
||||
with a key of their own. Nothing is judged for someone without one, and their Signals folder is
|
||||
not shown.
|
||||
|
||||
Jev is asked once for each item, on that key, with one question for each of the person's Signals
|
||||
that has not heard about the item yet.
|
||||
|
||||
## What someone sees
|
||||
|
||||
- **The sidebar.** A Signals folder comes first in the feed list, under All Subscriptions (#255,
|
||||
- **The sidebar.** Once Signals are on for someone, a Signals folder comes first in the feed list, under All Subscriptions (#255,
|
||||
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
|
||||
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
|
||||
person's Signals, each with its items and unread counted under its name.
|
||||
@@ -22,15 +30,19 @@ their Signals that has not heard about the item yet.
|
||||
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
|
||||
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
|
||||
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
|
||||
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with the + for a new one.
|
||||
- **The folder's page** lists the Signals, with the + for a new one.
|
||||
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
||||
Its header says what it listens for (the sentence, or the words), and that nothing is picked
|
||||
out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal
|
||||
Its header says what it listens for (the sentence, or the words), and, on people's own keys,
|
||||
that nothing is picked out without one. Edit changes the name, the words or the sentence; Delete removes the Signal
|
||||
and leaves the items in their feeds.
|
||||
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
|
||||
round. The counts follow without a reload (#258).
|
||||
- **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin
|
||||
forgets it. The key is never sent back to the page, which only learns whether one is set.
|
||||
- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a
|
||||
Signals switch: turning it on shows a password field for the key, which saves on leaving the
|
||||
field once TypeSafe has taken it (one short question on that key); a key TypeSafe refuses is
|
||||
not saved, and the page says so. Turning the switch off, or the bin, forgets the key and hides
|
||||
the folder; the Signals are kept. The key is never sent back to the page, which only learns
|
||||
whether one is set.
|
||||
|
||||
## How an item gets into a Signal
|
||||
|
||||
@@ -65,24 +77,39 @@ scan worker, so a slow TypeSafe never holds up a scan.
|
||||
`Db::unjudged` finds, newest first, up to 50 items that are:
|
||||
|
||||
- in a feed the Signal's owner subscribes to,
|
||||
- published (or, with no date, first seen) in the last 30 days (`SIGNAL_DAYS`),
|
||||
- not hidden from them by their block lists,
|
||||
- not yet in `signal_verdicts` for that Signal,
|
||||
- for someone with a key that TypeSafe has not refused since the daemon started.
|
||||
- for someone with a key that TypeSafe has not refused since the daemon started,
|
||||
- and either published (or, with no date, first seen) in the look-back window and not yet in
|
||||
`signal_verdicts` for that Signal, or picked out by it before it was edited (`recheck`), however
|
||||
old.
|
||||
|
||||
So a new Signal looks back a month, and after that sees each new item. A show subscribed to today
|
||||
has its back catalogue skipped, apart from its last month.
|
||||
The window is the admin's, Admin, Server, **Signals look back (days)** (`signal_days`, 7 by
|
||||
default, 1 to 365; #276): each item in it is a call, and a month of production's feeds was about
|
||||
8,000. So a new Signal looks back that far, and after that sees each new item. A show subscribed
|
||||
to today has its back catalogue skipped, apart from the window.
|
||||
|
||||
The recent half reads the index `entries_recent`, on each feed's items by date, the expression it
|
||||
is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274).
|
||||
|
||||
Rows for one item and one person are grouped, so an item is one call whatever the number of
|
||||
Signals. A full batch means there may be more, and the next pass starts at once. Otherwise the
|
||||
judge sleeps until woken or for 5 minutes.
|
||||
Signals. Two people's Signals never share a call, even on the server's key, where they did until
|
||||
#343: one person's description was then in the question that decided the other's verdicts. Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms
|
||||
each, a month of items took twenty minutes. Every call to Jev, Signals', icons' and key checks',
|
||||
takes a turn (`pace`, #277): TypeSafe's default limits are 80 requests and 100,000 input tokens a
|
||||
second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its
|
||||
guessed tokens' share, whichever is longer. A 429 holds every call back for its `Retry-After`, or
|
||||
a second, and ends the pass. Each `jev` span says how long it waited (`paced_ms`), and
|
||||
`ipx_jev_paced_seconds_total` adds them up; the dashboard draws calls and tokens a second against
|
||||
the limits. A full batch means there may be more, and the next pass
|
||||
starts at once. Otherwise the judge sleeps until woken or for 5 minutes.
|
||||
|
||||
### Every answer is kept
|
||||
|
||||
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
|
||||
an item with a row is never asked again. So the cost is one call per item per person, once.
|
||||
Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is
|
||||
asked again. The first start with sentences (#259) deleted every verdict, since they answered the
|
||||
Changing a Signal's name, words or sentence marks what it picked out to be asked again
|
||||
(`recheck`), and those stay in its list until answered; what it passed over stays passed over, and
|
||||
new items are asked the new question as they come (#276). The first start with sentences (#259) deleted every verdict, since they answered the
|
||||
old question.
|
||||
|
||||
## TypeSafe
|
||||
@@ -94,7 +121,7 @@ Questions in one call are judged independently and in parallel.
|
||||
|
||||
### A request
|
||||
|
||||
Sent with `Authorization: Bearer <the person's key>`, for an item and two of their Signals:
|
||||
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -122,7 +149,9 @@ Sent with `Authorization: Bearer <the person's key>`, for an item and two of the
|
||||
```
|
||||
|
||||
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
|
||||
cut to 2,000 characters (`TEXT_CHARS`).
|
||||
cut to 1,000 characters (`TEXT_CHARS`). It was 2,000 until #275: on 55 real items and six
|
||||
Signals, 1,000 changed none of 330 verdicts and saved 12% of the input tokens (1,266 to 1,112 a
|
||||
call); 500 changed one. Most of the input is the questions themselves, not the item.
|
||||
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
|
||||
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
|
||||
(#257).
|
||||
@@ -155,8 +184,10 @@ missing from the answer is left unjudged and asked again next pass.
|
||||
|
||||
| TypeSafe answers | What the judge does |
|
||||
|---|---|
|
||||
| 401, 402, 403 | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
|
||||
| 401, 402, 403, on the server's key | Everyone's key is refused. The pass ends and the queue waits for the next one, five minutes on. Logged: "TypeSafe refused the server's key for Signals". |
|
||||
| 401, 402, 403, on a person's key | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
|
||||
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
|
||||
| 429 | Over TypeSafe's limits. Every call waits for `Retry-After` or a second; the pass ends, what was in flight kept, and the rest wait for the next. Logged once: "TypeSafe asked Signals to slow down". |
|
||||
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
|
||||
|
||||
The error keeps TypeSafe's reply, cut to 500 characters, for example:
|
||||
@@ -238,9 +269,18 @@ anywhere else.
|
||||
{ "typesafe_key": "ts_…" }
|
||||
```
|
||||
|
||||
A string saves the key and lets the judge try it again. `null` or an empty string forgets it. A
|
||||
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
|
||||
more.
|
||||
A string is checked with TypeSafe, by one short question on that key, then saved, and the judge
|
||||
tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters.
|
||||
A key TypeSafe refuses is a `400` ("TypeSafe did not accept that key") and not saved; TypeSafe not
|
||||
answering is a `502`, and the key is not saved either. `GET /api/me` says `"typesafe_key_set":
|
||||
true` and nothing more about the key, and `"signals": "server"` or `"own"`, whose key is used.
|
||||
|
||||
### `PATCH /api/settings`, who pays
|
||||
|
||||
An admin's `{ "signals_on_server": true }` judges everyone's Signals on `TYPESAFE_KEY`, and
|
||||
`false` on each person's own. `GET /api/settings` has `signals_on_server` and
|
||||
`typesafe_server_key`, whether the server has a key to use; without one the setting changes
|
||||
nothing. A change wakes the judge and lets keys refused before be tried again.
|
||||
|
||||
### `POST /api/signals/icon`
|
||||
|
||||
@@ -351,10 +391,15 @@ erDiagram
|
||||
- **Refused keys are remembered in memory**, so a restart tries each once again.
|
||||
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
|
||||
words.
|
||||
- **There is no meter** of how many calls a key has made. It is one call per item in the person's
|
||||
feeds in the window: a person with 1,500 feeds busy enough to publish 3,000 items a month spends
|
||||
about 3,000 calls the first month and then as many as come in.
|
||||
- **Only the last 30 days** are ever judged, by publish date.
|
||||
- **The meter is the server's**, `ipx_jev_tokens_total` and `ipx_jev_requests_total` on
|
||||
`/metrics`, and the Signals row of the dashboard: tokens as TypeSafe's answers count them, by
|
||||
person (`user`, the account's name) and whose key paid (`key`, `server` or `own`; #278), with
|
||||
`ipx_signals_judged_total` and `ipx_signals_hits_total` the same way. A call shared by several
|
||||
people on the server's key is split by each one's share of its questions. Icon picks bill the
|
||||
person on the server's key, key checks the person on their own. The dashboard's **Who used what**
|
||||
sums them over its time range. It is
|
||||
one call per item in the person's feeds as they come, and per item in the window for a new Signal.
|
||||
- **Only the window** is judged by publish date, apart from what an edit asks again.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -365,4 +410,6 @@ erDiagram
|
||||
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
||||
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
|
||||
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
||||
`/signal.xml` is dated today, since only the last 30 days are judged.
|
||||
`/signal.xml` is dated today, since only the window is judged. They spend the TypeSafe key, so
|
||||
each runs only when the Signals code or the test has changed since it last passed (`signalsTest`;
|
||||
`IPX_SIGNALS_TESTS=1` runs them anyway).
|
||||
|
||||
@@ -51,8 +51,9 @@ Three ways, tried in order of how specific the claim is:
|
||||
3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck
|
||||
and any scripts use.
|
||||
|
||||
A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in
|
||||
the log. Change it:
|
||||
A database with no accounts creates **admin** on the next daemon start, with the password in
|
||||
`IPX_ADMIN_PASSWORD` if it is set, or a random one written to `admin-password` beside config.toml,
|
||||
readable by ipx's user alone. The log says where the password is, never what it is. Change it:
|
||||
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user passwd admin
|
||||
|
||||
@@ -254,6 +254,68 @@ table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev=
|
||||
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||
y += 8
|
||||
|
||||
# ---- ipx: Signals, items put to TypeSafe's Jev on each person's own key (#254)
|
||||
row("ipx: Signals")
|
||||
ts("Items judged", [prom('sum(increase(ipx_signals_judged_total{job="ipx"}[$__rate_interval]))', legend="judged"),
|
||||
prom('sum(increase(ipx_signals_hits_total{job="ipx"}[$__rate_interval]))', ref="B", legend="fit a Signal")],
|
||||
0, w=8, bars=True, desc="Each item is judged once against every Signal of its subscribers that has not had it; a burst follows a scan or a new Signal.")
|
||||
ts("Calls to Jev", [prom('sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="{{result}}"),
|
||||
prom('(sum(increase(ipx_jev_seconds_total{job="ipx"}[$__rate_interval])) - sum(increase(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))) / '
|
||||
'sum(increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="seconds a call")],
|
||||
8, w=8, bars=True, stack=True,
|
||||
desc="key_refused stops that person's Signals until they save another key; malformed stops the pass and is ipx's to fix; "
|
||||
"rate_limited is TypeSafe saying slow down (#277), and every call waits; "
|
||||
"error is TypeSafe failing or slow, and the item is asked again next pass. Seconds a call is TypeSafe's, without the wait for a turn.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "seconds a call"},
|
||||
"properties": [{"id": "unit", "value": "s"}, {"id": "custom.axisPlacement", "value": "right"},
|
||||
{"id": "custom.drawStyle", "value": "line"}, {"id": "custom.stacking", "value": {"mode": "none"}}]}]
|
||||
ts("Signals judge", [prom('max(ipx_signals_last_pass_age_seconds{job="ipx"})', legend="since the last pass"),
|
||||
prom('max(ipx_signals_keys_refused{job="ipx"})', ref="B", legend="keys refused")],
|
||||
16, w=8, unit="s", desc="It passes at least every five minutes; much longer means it is stuck on Jev. Keys refused, on the right, "
|
||||
"are people whose Signals wait for a new key.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "keys refused"},
|
||||
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
|
||||
y += 8
|
||||
ts("Tokens", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
|
||||
0, w=8, bars=True, stack=True, desc="What TypeSafe counted for each call, as its answers say: what Signals, icons and key checks cost.")
|
||||
ts("Tokens a call", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval])) / ignoring(kind) group_left '
|
||||
'sum(increase(ipx_jev_requests_total{job="ipx", result="ok"}[$__rate_interval]))', legend="{{kind}}")],
|
||||
8, w=8, desc="Most of the input is the questions, one for each Signal, then the item's text, cut at 1,000 characters.")
|
||||
# TypeSafe's default limits (#277): ipx paces itself to 60 calls and 80,000 input tokens a second.
|
||||
LIMIT = lambda n: [{"color": "transparent", "value": None}, {"color": "red", "value": n}]
|
||||
p = ts("Against TypeSafe's limits", [prom('sum(rate(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="calls a second"),
|
||||
prom('sum(rate(ipx_jev_tokens_total{job="ipx", kind="input"}[$__rate_interval])) / 1000', ref="B", legend="thousand input tokens a second"),
|
||||
prom('sum(rate(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))', ref="C", legend="seconds waited for a turn, a second")],
|
||||
16, w=8, desc="TypeSafe allows 80 calls and 100,000 input tokens a second; the red line is 80 on both scales (calls, and thousands of tokens). "
|
||||
"Waiting for a turn means ipx is holding itself under them, and is the time a new Signal's backfill takes longer for it.")
|
||||
p["fieldConfig"]["defaults"]["custom"]["thresholdsStyle"] = {"mode": "line"}
|
||||
p["fieldConfig"]["defaults"]["thresholds"] = {"mode": "absolute", "steps": LIMIT(80)}
|
||||
y += 8
|
||||
# By person and whose key paid (#278): who the server's TypeSafe bill is for. An item shared on the
|
||||
# server's key is split by each person's share of its questions.
|
||||
# Counts from before #278 have no person; user!="" leaves them out rather than drawing " ( key)".
|
||||
ts("Input tokens by person", [prom('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__rate_interval]))',
|
||||
legend="{{user}} ({{key}} key)")],
|
||||
0, w=8, bars=True, stack=True, desc="server is the server's TypeSafe key, what the server pays for; own is the person's own key.")
|
||||
ts("Items judged by person", [prom('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__rate_interval]))', legend="{{user}} ({{key}} key)"),
|
||||
prom('sum by (user) (increase(ipx_signals_hits_total{job="ipx", user!=""}[$__rate_interval]))', ref="B", legend="{{user}}: fit a Signal")],
|
||||
8, w=8, bars=True)
|
||||
def ptab(expr, ref):
|
||||
return {"refId": ref, "datasource": PROM, "expr": expr, "instant": True, "format": "table"}
|
||||
panel("table", "Who used what", 8, 8, 16,
|
||||
[ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__range]))', "A"),
|
||||
ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="output", user!=""}[$__range]))', "B"),
|
||||
ptab('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__range]))', "C")],
|
||||
description="Over the dashboard's time range: each person's input and output tokens and items judged, and whose key paid. "
|
||||
"Icon picks are on the server's key, key checks on the person's own.",
|
||||
transformations=[{"id": "merge", "options": {}},
|
||||
{"id": "organize", "options": {"excludeByName": {"Time": True},
|
||||
"renameByName": {"user": "Person", "key": "Key", "Value #A": "Input tokens",
|
||||
"Value #B": "Output tokens", "Value #C": "Items judged"}}}],
|
||||
options={"showHeader": True, "sortBy": [{"displayName": "Input tokens", "desc": True}]},
|
||||
fieldConfig={"defaults": {"decimals": 0}, "overrides": []})
|
||||
y += 8
|
||||
|
||||
# ---- ipx: the process, what it holds between requests and jobs (#194)
|
||||
row("ipx: process")
|
||||
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ipx-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "7.3.1",
|
||||
"@swc/core": "^1.16.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
||||
"scripts": {
|
||||
|
||||
@@ -60,6 +60,9 @@ module.exports = defineConfig({
|
||||
// production's Tempo and never opens production's database.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||
IPX_DATABASE_URL: '',
|
||||
// The server's TypeSafe key is the shell's: /src/.envrc's is a development key, for this.
|
||||
// Without one, the tests of the server paying for Signals see that it cannot.
|
||||
TYPESAFE_KEY: process.env.TYPESAFE_KEY ?? '',
|
||||
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
|
||||
},
|
||||
},
|
||||
|
||||
4
rustfmt.toml
Normal file
4
rustfmt.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Matches how the code was written by hand before rustfmt was adopted: long lines, and short
|
||||
# expressions kept on one line.
|
||||
max_width = 120
|
||||
use_small_heuristics = "Max"
|
||||
90
site/api.mjs
90
site/api.mjs
@@ -67,9 +67,11 @@ export const ipx = {
|
||||
},
|
||||
{
|
||||
method: 'PATCH', path: '/api/me', slug: 'patch-me',
|
||||
summary: 'Change the words that hide an item in every feed you read.',
|
||||
summary: 'Change the words that hide an item in every feed you read, or your TypeSafe key for Signals.',
|
||||
doc: '<p>A key is checked with TypeSafe, by one short question on it, before it is saved.</p>',
|
||||
body: 'MePatch', returns: '204 No Content',
|
||||
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']],
|
||||
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters; or a key TypeSafe did not accept.'],
|
||||
['502 Bad Gateway', 'TypeSafe could not be asked about the key just now. It is not saved.']],
|
||||
example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'),
|
||||
},
|
||||
{
|
||||
@@ -115,7 +117,7 @@ export const ipx = {
|
||||
{
|
||||
method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed',
|
||||
summary: 'Change your settings for a feed, or (an admin) the feed’s own.',
|
||||
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir and pinning are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
|
||||
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir, pinning and keeping a show from an OPML are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
|
||||
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||
body: 'FeedPatch', returns: '204 No Content',
|
||||
errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED],
|
||||
@@ -166,7 +168,7 @@ export const ipx = {
|
||||
items: [
|
||||
{
|
||||
method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries',
|
||||
summary: 'A page of one feed’s episodes, newest first unless sorted.',
|
||||
summary: 'A page of one feed’s episodes, newest first unless sorted. For an OPML list or a Patreon creator, those of the shows in it you subscribe to, together.',
|
||||
pathParams: [['id', 'String', 'The feed’s id.']],
|
||||
query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED],
|
||||
example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'),
|
||||
@@ -205,6 +207,54 @@ export const ipx = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'signals', name: 'signals', title: 'Signals',
|
||||
summary: 'Subjects picked out of every feed you subscribe to by TypeSafe.',
|
||||
items: [
|
||||
{
|
||||
method: 'GET', path: '/api/signals', slug: 'get-signals',
|
||||
summary: 'Your Signals, each with its items and unread counted.',
|
||||
returns: 'Vec<Signal>',
|
||||
example: ipxCurl('GET', '/api/signals'),
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/api/signals', slug: 'post-signals',
|
||||
summary: 'Make a Signal. It looks back over the last week (an admin’s setting), then at each new item.',
|
||||
doc: '<p>Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (<code>signals_on_server</code>), otherwise on yours, saved with <a href="/docs/api/ipx/auth/patch-me"><code>PATCH /api/me</code></a>. Without a key, nothing is picked out.</p>',
|
||||
body: 'SignalBody', returns: '{ id: i64 }',
|
||||
errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']],
|
||||
example: ipxCurl('POST', '/api/signals', '{"name":"Formula 1","keywords":["formula 1","f1"],"description":"Grand prix racing, not road cars."}'),
|
||||
},
|
||||
{
|
||||
method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal',
|
||||
summary: 'Change a Signal. A new name, words or description asks again about what it picked out, and about new items as they come; a new icon asks nothing.',
|
||||
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||
body: 'SignalBody', returns: '204 No Content',
|
||||
errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']],
|
||||
example: ipxCurl('PATCH', '/api/signals/3', '{"keywords":["f1","grand prix"]}'),
|
||||
},
|
||||
{
|
||||
method: 'DELETE', path: '/api/signals/{id}', slug: 'delete-signal',
|
||||
summary: 'Delete a Signal. Its items stay in their feeds.',
|
||||
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||
returns: '204 No Content', errors: [['404 Not Found', 'No Signal of yours has that id.']],
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/api/signals/{id}/entries', slug: 'get-signal-entries',
|
||||
summary: 'A page of the items picked out for a Signal, as a feed’s list is.',
|
||||
pathParams: [['id', 'i64', 'The Signal’s id.']],
|
||||
query: 'Page', returns: 'EntryPage', errors: [['404 Not Found', 'No Signal of yours has that id.']],
|
||||
example: ipxCurl('GET', '/api/signals/3/entries?filter=unread&limit=10'),
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/api/signals/icon', slug: 'post-signal-icon',
|
||||
summary: 'Have TypeSafe choose a Signal’s icon from the ones offered, on the server’s TypeSafe key.',
|
||||
body: 'IconAsk', returns: '{ icon: String }',
|
||||
errors: [['400 Bad Request', 'No icons, more than 60, or one that is not a style and a name.'],
|
||||
['404 Not Found', 'The server has no TypeSafe key.'], ['502 Bad Gateway', 'TypeSafe could not choose just now.']],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'files', name: 'files', title: 'Files',
|
||||
summary: 'Downloading, deleting and playing an episode’s file.',
|
||||
@@ -379,8 +429,28 @@ export const ipx = {
|
||||
['sign_out', 'Option<String>', 'Where signing out should go, for someone single sign-on signed in.'],
|
||||
['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'],
|
||||
['version', 'String', 'ipx’s version.'],
|
||||
['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'],
|
||||
['signals', 'String', 'Whose key Signals are judged on: <code>server</code>, for everyone, or <code>own</code>, each person’s.'],
|
||||
['signal_days', 'u64', 'How many days back a new Signal reads.'],
|
||||
] },
|
||||
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.'],
|
||||
['typesafe_key', 'Option<Option<String>>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; <code>null</code> or empty forgets it.']] },
|
||||
{ name: 'Signal', doc: 'A Signal in your sidebar.', fields: [
|
||||
['id', 'i64', ''], ['name', 'String', ''], ['keywords', 'Vec<String>', 'The words it listens for.'],
|
||||
['description', 'Option<String>', 'What you mean, in your own words.'],
|
||||
['icon', 'Option<String>', 'A Font Awesome icon, such as <code>solid/robot</code>; none shows its initials.'],
|
||||
['items', 'i64', 'Items picked out that are still in a feed you subscribe to.'], ['unread', 'i64', 'Those of them not read.'],
|
||||
] },
|
||||
{ name: 'SignalBody', doc: 'A Signal to make, or what to change of one. Leave out what stays.', fields: [
|
||||
['name', 'Option<String>', 'Up to 100 characters; needed for a new one.'],
|
||||
['keywords', 'Option<Vec<String>>', '1 to 50 words, each up to 200 characters; needed for a new one.'],
|
||||
['description', 'Option<Option<String>>', 'Up to 500 characters; <code>null</code> or empty clears it.'],
|
||||
['icon', 'Option<Option<String>>', '<code>solid/</code>, <code>regular/</code> or <code>brands/</code> and a name; <code>null</code> or empty clears it.'],
|
||||
] },
|
||||
{ name: 'IconAsk', doc: 'The icons to choose a Signal’s from.', fields: [
|
||||
['name', 'String', ''], ['keywords', 'Vec<String>', ''], ['description', 'Option<String>', ''],
|
||||
['candidates', 'Vec<(String, String)>', '1 to 60 of <code>[icon, label]</code>, the label its name and search terms.'],
|
||||
] },
|
||||
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.']] },
|
||||
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
|
||||
{ name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] },
|
||||
{ name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
|
||||
@@ -407,6 +477,7 @@ export const ipx = {
|
||||
['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''],
|
||||
['subscribers', 'i64', 'People on this server, you included.'],
|
||||
['pinned', 'bool', 'At the top of your list.'],
|
||||
['kept', 'bool', 'From an OPML, and yours to keep as a feed of its own should the OPML drop it or you leave the OPML.'],
|
||||
['private', 'bool', 'Kept out of the directory.'],
|
||||
['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'],
|
||||
] },
|
||||
@@ -423,6 +494,7 @@ export const ipx = {
|
||||
['allow_explicit', 'Option<bool>', 'Yours.'], ['auto_download', 'Option<bool>', 'Yours.'],
|
||||
['max_new_per_check', 'Option<Option<usize>>', 'Yours; <code>null</code> for the server’s.'],
|
||||
['pinned', 'Option<bool>', 'Yours.'],
|
||||
['kept', 'Option<bool>', 'Yours: keep a show from an OPML should the OPML drop it.'],
|
||||
['deadair', 'Option<Option<bool>>', 'Yours; <code>null</code> follows its OPML list’s.'],
|
||||
['deadair_pause', 'Option<i64>', 'Yours: 200, 300, 400 or 500.'],
|
||||
['private', 'Option<bool>', 'Anyone subscribed may mark it private; only an admin unmarks it.'],
|
||||
@@ -447,6 +519,8 @@ export const ipx = {
|
||||
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
|
||||
['published', 'Option<i64>', ''],
|
||||
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
|
||||
['markdown', 'Option<String>', 'The item’s Markdown as written, when its feed sends Markdown beside its HTML (RSS’s source:markdown).'],
|
||||
['markdown_html', 'Option<String>', 'That Markdown made HTML, sanitized as description is.'],
|
||||
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
|
||||
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
|
||||
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
|
||||
@@ -493,6 +567,7 @@ export const ipx = {
|
||||
['download_error', '{ feed, enclosure, msg, url }', ''],
|
||||
['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'],
|
||||
['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'],
|
||||
['signal_hits', '{ user, hits }', 'Items were picked out for someone’s Signals; theirs to fetch again.'],
|
||||
] },
|
||||
{ name: 'Settings', doc: 'The server’s settings.', fields: [
|
||||
['schedule', 'String', 'How often feeds are checked, such as <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
|
||||
@@ -502,12 +577,17 @@ export const ipx = {
|
||||
['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'],
|
||||
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
|
||||
['directory_share', 'bool', ''],
|
||||
['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'],
|
||||
['signal_days', 'u64', 'How many days back a new Signal reads; 7 unless an admin sets it.'],
|
||||
['typesafe_server_key', 'bool', 'The server has a TypeSafe key (<code>TYPESAFE_KEY</code>); without one, <code>signals_on_server</code> changes nothing.'],
|
||||
] },
|
||||
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
|
||||
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
|
||||
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
|
||||
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
|
||||
['directory_share', 'Option<bool>', ''],
|
||||
['signals_on_server', 'Option<bool>', 'Judge everyone’s Signals on the server’s TypeSafe key.'],
|
||||
['signal_days', 'Option<u64>', '1 to 365.'],
|
||||
] },
|
||||
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
|
||||
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },
|
||||
|
||||
@@ -50,6 +50,7 @@ docker build -f Dockerfile.web -t ipx-web .</code></pre>
|
||||
<tbody>
|
||||
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
|
||||
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
|
||||
<tr><td><code>TYPESAFE_KEY</code></td><td>The server's own <a href="https://typesafe.ai">typesafe.ai</a> API key: it picks a podcast's Directory category and a Signal's icon, and, when an admin turns it on, pays for everyone's Signals</td></tr>
|
||||
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
|
||||
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
|
||||
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>
|
||||
|
||||
@@ -56,17 +56,7 @@
|
||||
|
||||
<!-- Each feature beside the part of the app that shows it, cut from a scratch iPX on public
|
||||
podcasts, in a navy frame like the dial's. -->
|
||||
<section class="does" aria-labelledby="does">
|
||||
<h2 id="does">What it does</h2>
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>An account for everyone at home</h3>
|
||||
<p>Each person has their own shows, sees what they have and have not heard, and picks up where they left off.</p>
|
||||
</div>
|
||||
<figure class="frame"><img src="/shots/feeds.webp" width="290" height="276" loading="lazy"
|
||||
alt="The feed list: Directory, Currently Listening and All Subscriptions, then each show with its cover and how many of its episodes are unread."></figure>
|
||||
</div>
|
||||
<section class="does" aria-label="What it does">
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
@@ -90,8 +80,8 @@
|
||||
episode ends sooner. CSS only; the page runs no script of its own. -->
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>Cut the dead air</h3>
|
||||
<p>Turn on DeadAir for a show and its long silences shrink to a short pause, so episodes finish sooner without anyone talking faster. You choose how long a pause to leave.</p>
|
||||
<h3>All talk, no dead air</h3>
|
||||
<p>Radio has a name for silence on air: dead air. Turn on DeadAir for a show and its long silences shrink to a pause as short as you like. Everyone still talks at their own pace; the episode just ends sooner.</p>
|
||||
</div>
|
||||
<figure class="frame deadair" role="img" aria-label="A waveform whose silent gaps shrink to short pauses, so the episode ends sooner.">
|
||||
<div class="wave" aria-hidden="true">
|
||||
@@ -105,6 +95,15 @@
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>Tune out the noise</h3>
|
||||
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last week, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
|
||||
</div>
|
||||
<figure class="frame"><img class="cut" src="/shots/signals.webp" width="874" height="398" loading="lazy"
|
||||
alt="A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica."></figure>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="say">
|
||||
<h3>See what others listen to</h3>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
BIN
site/shots/signals.webp
Normal file
BIN
site/shots/signals.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -64,12 +64,15 @@ h1{margin:0;color:var(--navy);font-weight:900;font-size:clamp(6.5rem,24vw,15rem)
|
||||
|
||||
h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4vw,2.4rem);line-height:1.1;letter-spacing:-.02em}
|
||||
.does,.then{padding-block:4rem 0}
|
||||
/* Each feature on one row: what it does on the left, beside the part of the app that shows it,
|
||||
/* Each feature on one row: what it does beside the part of the app that shows it,
|
||||
framed in the dial's navy at no more than its own size. */
|
||||
.feature{display:grid;grid-template-columns:minmax(0,22rem) minmax(0,1fr);gap:clamp(1.5rem,5vw,4rem);align-items:center;padding-block:2.5rem}
|
||||
.feature h3{margin:0 0 .5rem;color:var(--navy);font-weight:750;font-size:1.35rem;line-height:1.25}
|
||||
.feature p{margin:0;max-width:34rem}
|
||||
.frame{margin:0;justify-self:start;max-width:100%;padding:clamp(.6rem,1.6vw,1.1rem);background:var(--navy);border-radius:1.1rem}
|
||||
/* Every other row the picture comes first, so the page zigzags rather than running down one side. */
|
||||
.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr) minmax(0,22rem)}
|
||||
.feature:nth-of-type(even) .say{order:2}
|
||||
.frame{margin:0;justify-self:center;max-width:100%;padding:clamp(.6rem,1.6vw,1.1rem);background:var(--navy);border-radius:1.1rem}
|
||||
.frame img{display:block;max-width:100%;height:auto;border-radius:.55rem}
|
||||
/* DeadAir: the track is the episode's full length; talk keeps its width, each silence lights
|
||||
in the needle's orange and shrinks to a pause, and the end comes in from the right. */
|
||||
@@ -175,7 +178,8 @@ footer p{margin:0}
|
||||
main,.bar,footer{padding-inline:1rem}
|
||||
.bar nav{gap:1rem}
|
||||
.hero{padding-block:1.5rem 2rem}
|
||||
.feature{grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
|
||||
.feature,.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
|
||||
.feature:nth-of-type(even) .say{order:0}
|
||||
/* A wide crop at this width is too small to read: its left part, larger. */
|
||||
.frame img.cut{width:100%;aspect-ratio:4/3;object-fit:cover;object-position:0 0}
|
||||
.frame img.cut.player{aspect-ratio:2.4/1}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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();
|
||||
const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100;
|
||||
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,21 @@ for (const sel of Object.keys(blocks)) {
|
||||
}
|
||||
themes['modern'] = base;
|
||||
|
||||
// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does.
|
||||
const oklab = h => {
|
||||
const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||
const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt);
|
||||
return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];
|
||||
};
|
||||
const fromOklab = ([L, a, b]) => {
|
||||
const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3);
|
||||
return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s]
|
||||
.map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055)
|
||||
.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
for (const p of Object.values(themes)) { const f = oklab(p.fg), g = oklab(p.bg), k = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); }
|
||||
RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits
|
||||
|
||||
let bad = 0;
|
||||
for (const [name, p] of Object.entries(themes)) {
|
||||
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||
|
||||
@@ -16,7 +16,7 @@ function makeContext(html, script, { media = false } = {}) {
|
||||
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(){},
|
||||
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, 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) });
|
||||
|
||||
@@ -134,6 +134,8 @@ download_dir = "${root}/downloads"
|
||||
socket = "${root}/ipx.sock"
|
||||
schedule = "every 60m"
|
||||
max_new_per_check = 1
|
||||
# The generated feeds are on loopback, which ipx refuses otherwise (#320).
|
||||
fetch_internal = ["127.0.0.1"]
|
||||
|
||||
[torrent]
|
||||
enabled = false
|
||||
|
||||
@@ -54,7 +54,7 @@ const drive = PAGE === 'admin.html' ? [
|
||||
['keysModal', () => ctx.keysModal()],
|
||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||
// of a vm context the way var and function declarations do.
|
||||
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
||||
['groupHead', () => ctx.groupHead(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
||||
];
|
||||
for (const [name, fn] of drive) {
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,14 @@ test('esc makes text safe to put in markup and attributes', () => {
|
||||
assert.equal(run('esc(undefined)'), '');
|
||||
});
|
||||
|
||||
test('webUrl keeps only web addresses from a feed', () => {
|
||||
assert.equal(run(`webUrl('https://example.com/a')`), 'https://example.com/a');
|
||||
assert.equal(run(`webUrl('HTTP://example.com/')`), 'HTTP://example.com/');
|
||||
for (const u of ['javascript:alert(1)', ' javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '//example.com', ''])
|
||||
assert.equal(run(`webUrl(${JSON.stringify(u)})`), null, u);
|
||||
assert.equal(run('webUrl(null)'), null);
|
||||
});
|
||||
|
||||
test('clock reads seconds as a player does', () => {
|
||||
assert.equal(run('clock(0)'), '0:00');
|
||||
assert.equal(run('clock(65)'), '1:05');
|
||||
@@ -144,6 +152,18 @@ test('a Patreon feed is told by its address', () => {
|
||||
assert.equal(run('isPatreon(null)'), false);
|
||||
});
|
||||
|
||||
test("All Subscriptions' unread count leaves out feeds hidden from it, and an OPML's shows with it", () => {
|
||||
run(`S.feeds = [
|
||||
{id: 'o', group: null, unread: 0, hide_from_all: true},
|
||||
{id: 'a', group: 'o', unread: 2, hide_from_all: false},
|
||||
{id: 'c', group: null, unread: 3, hide_from_all: true},
|
||||
{id: 'd', group: null, unread: 5, hide_from_all: false}]`);
|
||||
assert.equal(run('unreadInAll()'), 5);
|
||||
run(`S.feeds[0].hide_from_all = false`);
|
||||
assert.equal(run('unreadInAll()'), 7);
|
||||
run(`S.feeds = []`);
|
||||
});
|
||||
|
||||
test('a Signal is a place of its own, found by its id', () => {
|
||||
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
|
||||
assert.equal(run(`signalId(':signal:7')`), 7);
|
||||
@@ -199,3 +219,12 @@ test('Jev is offered the icons found for each word of a Signal', () => {
|
||||
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
||||
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
||||
});
|
||||
|
||||
test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => {
|
||||
const on = me => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); };
|
||||
assert.equal(on({ signals: 'server', typesafe_key_set: false }), true);
|
||||
assert.equal(on({ signals: 'own', typesafe_key_set: true }), true);
|
||||
assert.equal(on({ signals: 'own', typesafe_key_set: false }), false);
|
||||
assert.equal(on(null), false);
|
||||
run('S.me = null');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { TOKEN } = require('./global-setup');
|
||||
|
||||
// Signals' tests run only when the Signals code has changed since each last passed: they spend the
|
||||
// TypeSafe key (#271). The Signals code is signals.rs and signals.ts whole, and the lines naming
|
||||
// Signals, TypeSafe or Jev anywhere else in the daemon and the page; with the test's own source,
|
||||
// hashed, and kept under target/ once the test passes. IPX_SIGNALS_TESTS=1 runs them anyway.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ROOT = path.join(__dirname, '../..');
|
||||
const SIGNALS_CODE = (() => {
|
||||
const h = require('node:crypto').createHash('sha256');
|
||||
for (const dir of ['crates/ipx/src', 'web/src']) {
|
||||
for (const f of fs.readdirSync(path.join(ROOT, dir)).sort()) {
|
||||
const text = fs.readFileSync(path.join(ROOT, dir, f), 'utf8');
|
||||
h.update(f + '\n' + (/^signals\./.test(f) ? text : text.split('\n').filter(l => /signal|typesafe|jev/i.test(l)).join('\n')));
|
||||
}
|
||||
}
|
||||
return h.digest('hex');
|
||||
})();
|
||||
function signalsTest(name, fn) {
|
||||
const stamp = path.join(ROOT, 'target/signals-tests',
|
||||
require('node:crypto').createHash('sha256').update(name).digest('hex').slice(0, 16));
|
||||
const want = require('node:crypto').createHash('sha256').update(SIGNALS_CODE + fn.toString()).digest('hex');
|
||||
// Playwright reads the fixtures a test asks for from its parameters; these ask for the page alone.
|
||||
test(name, async ({ page }) => {
|
||||
let had = '';
|
||||
try { had = fs.readFileSync(stamp, 'utf8'); } catch {}
|
||||
test.skip(had === want && !process.env.IPX_SIGNALS_TESTS, 'the Signals code is as it was when this last passed');
|
||||
await fn({ page });
|
||||
fs.mkdirSync(path.dirname(stamp), { recursive: true });
|
||||
fs.writeFileSync(stamp, want);
|
||||
});
|
||||
}
|
||||
|
||||
// The token sets a cookie, so every test starts by presenting it once.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/?token=${TOKEN}`);
|
||||
@@ -415,9 +447,12 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
||||
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
||||
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
||||
|
||||
// The subscription's own page lists what is inside it.
|
||||
// The subscription's own page lists the items of the shows inside it, together (#283).
|
||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||
await expect(page.locator('.childrow')).toHaveCount(2);
|
||||
await page.locator('#content .tabs button[data-f="all"]').click();
|
||||
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toBeVisible();
|
||||
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
|
||||
await expect(page.locator('.childrow')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
||||
@@ -450,8 +485,68 @@ test('inside an OPML, feeds with unread items are listed first', async ({ page }
|
||||
await page.reload();
|
||||
const want = ['Grouped Show', 'Aardvark Radio'];
|
||||
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
||||
// The OPML's Unread tab has the unread show's item and not the read one's (#283).
|
||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||
await expect(page.locator('.childrow b')).toHaveText(want);
|
||||
await page.locator('#content .tabs button[data-f="unread"]').click();
|
||||
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
|
||||
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a show inside an OPML can be kept, and only it', async ({ page }) => {
|
||||
await page.evaluate(() =>
|
||||
fetch('/api/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
||||
}));
|
||||
const chev = page.locator('.feed.group .chev');
|
||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||
if (await chev.getAttribute('aria-expanded') === 'false') await chev.click();
|
||||
await page.locator('#feedlist').getByText('Aardvark Radio').click();
|
||||
|
||||
// A plus on the show's page; pressed, a check (#280).
|
||||
const keep = page.locator('#content .acts [data-a="keep"]');
|
||||
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(keep).toHaveAttribute('title', /Test Subscriptions drops it/);
|
||||
await keep.click();
|
||||
await expect(keep).toHaveAttribute('aria-pressed', 'true');
|
||||
const kept = await page.evaluate(() => fetch('/api/feeds').then((r) => r.json()));
|
||||
expect((kept.feeds || kept).find((f) => f.title === 'Aardvark Radio')?.kept).toBe(true);
|
||||
await keep.click();
|
||||
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// The OPML itself has no Keep.
|
||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||
await expect(page.locator('#content h2')).toHaveText('Test Subscriptions');
|
||||
await expect(page.locator('#content [data-a="keep"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an item sent as HTML and Markdown switches between them', async ({ page }) => {
|
||||
const api = (p, init) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
|
||||
.then((r) => r.json().catch(() => null)), [p, init]);
|
||||
const added = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/markdown.xml' }) });
|
||||
const id = added.id || added.feed?.id || 'markdown-blog';
|
||||
await page.reload();
|
||||
await page.locator('#feedlist .feed', { hasText: 'Markdown Blog' }).click({ timeout: 20_000 });
|
||||
await page.locator('#content .tabs button[data-f="all"]').click();
|
||||
await page.locator('.ep', { hasText: 'Both Ways' }).click();
|
||||
|
||||
// The publisher's HTML first; the choice is between the title and the item's buttons (#285).
|
||||
const view = page.locator('#detail .dmeta .tview');
|
||||
await expect(view).toHaveValue('html');
|
||||
await expect(page.locator('#detail .dbody b')).toHaveText('HTML');
|
||||
await view.selectOption('markdown');
|
||||
await expect(page.locator('#detail .dbody strong')).toHaveText('Markdown');
|
||||
await expect(view.locator('option')).toHaveText(['HTML', 'Markdown']);
|
||||
// Kept for the next item; set back so nothing after this sees it.
|
||||
expect(await page.evaluate(() => localStorage.getItem('ipx.textview'))).toBe('markdown');
|
||||
await view.selectOption('html');
|
||||
|
||||
// An item with no Markdown has no choice to make.
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
await page.locator('.ep').first().click();
|
||||
await expect(page.locator('#detail .tview')).toHaveCount(0);
|
||||
await api('/api/feeds/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
});
|
||||
|
||||
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
||||
@@ -599,6 +694,8 @@ test('a second person has their own feeds and their own read state', async ({ br
|
||||
// Hiding the way in is not the guard; the server is.
|
||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
||||
// Nor may Sam mark a feed they do not read private, keeping it out of the directory (#333).
|
||||
expect((await page.request.patch('/api/feeds/test-show', { data: { private: true } })).status()).toBe(404);
|
||||
|
||||
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
||||
// files, but Sam's own read state.
|
||||
@@ -1143,7 +1240,8 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||
async () => { await page.locator('#feedlist .sigs').click(); await page.locator('#content .acts [data-a="new"]').click(); },
|
||||
// The Signals folder is not there without a key (#271); the dialog is the same either way.
|
||||
() => page.evaluate(() => signalModal()),
|
||||
];
|
||||
for (const open of dialogs) {
|
||||
await open();
|
||||
@@ -1810,6 +1908,33 @@ test('a refresh button turns from the press to the end of the scan it asked for'
|
||||
expect(await spin('#scanAll .i')).toBe('none');
|
||||
});
|
||||
|
||||
test("an open feed's page follows its row: an error a later check clears goes", async ({ page }) => {
|
||||
// The header was drawn once, on opening, and kept a cleared error until reopened (#316).
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
const head = page.locator('.fhead .meta');
|
||||
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: 'connecting: timed out', failing: null }));
|
||||
await expect(head.locator('.fail')).toHaveText('connecting: timed out');
|
||||
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: null, entries: 99 }));
|
||||
await expect(head.locator('.fail')).toHaveCount(0);
|
||||
await expect(head.locator('.stat')).toContainText('99 items');
|
||||
});
|
||||
|
||||
test.describe('with motion reduced', () => {
|
||||
test.use({ reducedMotion: 'reduce' });
|
||||
|
||||
test('a check running still shows, as a pulse and in its label', async ({ page }) => {
|
||||
// Turning was all that showed it, and with motion reduced nothing turned (#310).
|
||||
const all = page.locator('#scanAll');
|
||||
await page.evaluate(() => spinStart(null));
|
||||
await expect(all).toHaveAttribute('aria-busy', 'true');
|
||||
await expect(all).toHaveAttribute('aria-label', 'Checking for new items…');
|
||||
expect(await all.locator('.i').evaluate(el => getComputedStyle(el).animationName)).toBe('ipxpulse');
|
||||
await page.evaluate(() => spinEnd({ only: null, forced: true }));
|
||||
await expect(all).not.toHaveAttribute('aria-busy');
|
||||
await expect(all).toHaveAttribute('aria-label', 'Check every feed for new items');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('on a narrow phone', () => {
|
||||
test.use({ viewport: { width: 360, height: 760 } });
|
||||
|
||||
@@ -1855,6 +1980,22 @@ test('a page brought back into view catches up with what was read elsewhere', as
|
||||
await expect(badge).toHaveText(String(before - 1));
|
||||
});
|
||||
|
||||
test('a search box has an x that clears it once something is typed', async ({ page }) => {
|
||||
// #291: inside the box, at its right end, and the list comes back whole.
|
||||
const box = page.locator('#feedFilter');
|
||||
const x = page.locator('.searchwrap .clearx');
|
||||
const all = await page.locator('#feedlist .feed').count();
|
||||
await expect(x).toBeHidden();
|
||||
await box.fill('zzz-nothing');
|
||||
await expect(x).toBeVisible();
|
||||
await expect(page.locator('#feedlist .feed')).toHaveCount(0);
|
||||
await x.click();
|
||||
await expect(box).toHaveValue('');
|
||||
await expect(box).toBeFocused();
|
||||
await expect(x).toBeHidden();
|
||||
await expect(page.locator('#feedlist .feed')).toHaveCount(all);
|
||||
});
|
||||
|
||||
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
|
||||
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
|
||||
const open = async () => {
|
||||
@@ -2073,7 +2214,7 @@ test('the page comes compressed, and the compressed copies cannot be asked for b
|
||||
}
|
||||
});
|
||||
|
||||
test('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
|
||||
signalsTest('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
|
||||
// #254. Jev is the fixture server's stand-in; the show is added here and removed at the end.
|
||||
expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204);
|
||||
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
||||
@@ -2116,7 +2257,7 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
});
|
||||
|
||||
test('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
|
||||
signalsTest('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
|
||||
await page.reload();
|
||||
@@ -2127,3 +2268,105 @@ test('a Signal with no key to judge it links to where one is got and where it go
|
||||
await page.keyboard.press('Escape');
|
||||
await page.request.delete(`/api/signals/${id}`);
|
||||
});
|
||||
|
||||
signalsTest('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => {
|
||||
// #271. The test daemon has no key of its own, so each person brings theirs.
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
await page.reload();
|
||||
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
|
||||
await page.evaluate(() => prefsModal());
|
||||
await expect(page.locator('#ssigkey')).toBeHidden();
|
||||
await page.locator('#ssigon').check();
|
||||
await page.locator('#stskey').fill('bad-key');
|
||||
await page.locator('#stskey').press('Enter');
|
||||
await page.locator('#stskey').blur();
|
||||
await expect(page.locator('.toast', { hasText: 'TypeSafe did not accept that key' })).toBeVisible();
|
||||
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
|
||||
await page.locator('#stskey').fill('test-key');
|
||||
await page.locator('#stskey').blur();
|
||||
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
|
||||
page.once('dialog', d => d.accept());
|
||||
await page.locator('#ssigon').uncheck();
|
||||
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
|
||||
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
signalsTest('the server pays for Signals only when it has a key to pay with', async ({ page }) => {
|
||||
// #271. With the shell's TYPESAFE_KEY (/src/.envrc's development key) the server can pay;
|
||||
// Jev itself is still the fixture server's stand-in.
|
||||
const { typesafe_server_key: has } = await (await page.request.get('/api/settings')).json();
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
expect((await page.request.patch('/api/settings', { data: { signals_on_server: true } })).status()).toBe(204);
|
||||
try {
|
||||
expect((await (await page.request.get('/api/me')).json()).signals).toBe(has ? 'server' : 'own');
|
||||
await page.goto('/admin#server');
|
||||
if (!has) { await expect(page.locator('#gsig')).toBeDisabled(); return; }
|
||||
await expect(page.locator('#gsig')).toBeChecked();
|
||||
|
||||
// No key of your own, and Signals are there, judged on the server's.
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
|
||||
await page.evaluate(() => prefsModal());
|
||||
await expect(page.locator('#stskey')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
||||
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Bread', keywords: ['baking'] } })).json();
|
||||
const shown = async () => (await (await page.request.get(`/api/signals/${id}/entries`)).json()).entries.map(e => e.title);
|
||||
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Baking bread']);
|
||||
await page.request.delete(`/api/signals/${id}`);
|
||||
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
||||
} finally {
|
||||
await page.request.patch('/api/settings', { data: { signals_on_server: false } });
|
||||
}
|
||||
});
|
||||
|
||||
test('a dialog keeps the keyboard inside it and gives it back on closing', async ({ page }) => {
|
||||
// #306: Tab walked out of a dialog into the toolbar, and closing one left focus on the page.
|
||||
await page.locator('#addFeed').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#modalCard[role=dialog]')).toHaveAttribute('aria-labelledby', /.+/);
|
||||
await expect(page.locator('#nurl')).toBeFocused();
|
||||
// Labels name their boxes (#307).
|
||||
await expect(page.getByRole('textbox', { name: 'Feed URL' })).toHaveCount(1);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
// Past the last control Tab goes to the browser's own toolbar (the body, from here), never the page behind.
|
||||
expect(await page.evaluate(() => { const a = document.activeElement; return a === document.body || document.querySelector('#modalCard').contains(a); }), `Tab ${i + 1} stays out of the page behind`).toBe(true);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#modal.on')).toBeHidden();
|
||||
await expect(page.locator('#addFeed')).toBeFocused();
|
||||
expect(await page.locator('#shell').evaluate(e => e.inert)).toBe(false);
|
||||
});
|
||||
|
||||
test('an item opens from the keyboard, and its row actions show while focused', async ({ page }) => {
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
|
||||
const row = page.locator('#eps .ep').first();
|
||||
// #298: the title is a button, so Tab reaches the item itself.
|
||||
await row.locator('button.t').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(row).toHaveClass(/\bsel\b/);
|
||||
// #300: the read toggle is named for what it does, not its dot.
|
||||
await expect(row.locator('.st')).toHaveAttribute('aria-label', /^Mark (read|unread)$/);
|
||||
// #299: focus shows what hover shows.
|
||||
await page.mouse.move(0, 0);
|
||||
const act = row.locator('.rowacts button').first();
|
||||
if (await act.count()) {
|
||||
await act.focus();
|
||||
await expect(row.locator('.rowacts')).toHaveCSS('opacity', '1');
|
||||
}
|
||||
// #298: a feed in the list is a link to a screen reader, the open one current.
|
||||
await expect(page.locator('#feedlist .feed[aria-current=page]')).toHaveAttribute('role', 'link');
|
||||
});
|
||||
|
||||
test('an error toast is announced and stays until it is dismissed', async ({ page }) => {
|
||||
// #303: role=status, and no timer on an error.
|
||||
await expect(page.locator('#toasts')).toHaveAttribute('role', 'status');
|
||||
await page.evaluate(() => toast('Unable to play this file. Try again, or download it again.', true));
|
||||
const t = page.locator('.toast.bad').last();
|
||||
await page.waitForTimeout(6500);
|
||||
await expect(t).toBeVisible();
|
||||
await t.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await expect(t).toHaveCount(0);
|
||||
});
|
||||
|
||||
8
tests/ui/fixtures/markdown.xml
Normal file
8
tests/ui/fixtures/markdown.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:source="https://source.scripting.com/">
|
||||
<channel><title>Markdown Blog</title><link>http://127.0.0.1:8792/</link><description>HTML and Markdown both, as Scripting News sends.</description>
|
||||
<item><title>Both Ways</title><guid>md-1</guid>
|
||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||
<description><p>The <b>HTML</b> copy.</p></description>
|
||||
<source:markdown>The **Markdown** copy.</source:markdown></item>
|
||||
</channel></rss>
|
||||
@@ -13,6 +13,11 @@ http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', c => { body += c; });
|
||||
req.on('end', () => {
|
||||
// As TypeSafe answers a key it does not know (#271).
|
||||
if (req.headers.authorization === 'Bearer bad-key') {
|
||||
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
|
||||
return;
|
||||
}
|
||||
const { state, questions } = JSON.parse(body);
|
||||
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
||||
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
||||
@@ -21,7 +26,8 @@ http.createServer((req, res) => {
|
||||
const title = String(state).toLowerCase();
|
||||
const answers = {};
|
||||
for (const [id, q] of Object.entries(questions)) {
|
||||
const words = /described by these words: ([^.]*)\./.exec(q.instructions)[1].split(', ');
|
||||
// A key's check asks about no Signal's words.
|
||||
const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? [];
|
||||
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
|
||||
|
||||
@@ -32,6 +32,8 @@ download_dir = "${path.join(root, 'downloads')}"
|
||||
socket = "${path.join(root, 'ipx.sock')}"
|
||||
schedule = "every 60m"
|
||||
max_new_per_check = 1
|
||||
# The fixture feeds are on loopback, which ipx refuses otherwise (#320).
|
||||
fetch_internal = ["127.0.0.1"]
|
||||
directory_url = "${DIRECTORY}"
|
||||
directory_token = "${DIRECTORY_TOKEN}"
|
||||
directory_share = true
|
||||
@@ -75,6 +77,8 @@ auto_download = false
|
||||
fs.writeFileSync(path.join(root, 'directory.toml'), `
|
||||
bind = "127.0.0.1:8795"
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||
# The fixture feeds are on loopback, which the refresher refuses otherwise (#329).
|
||||
fetch_internal = ["127.0.0.1"]
|
||||
|
||||
[[tokens]]
|
||||
name = "test"
|
||||
|
||||
@@ -11,6 +11,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.0] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- An item opens from the keyboard and with a screen reader: its title is a button. The feed list's rows and the Directory's shows are links to a screen reader, the open one marked current (#298).
|
||||
- An item's Play, Download and Delete buttons show while the keyboard is on one, as they do under the pointer (#299).
|
||||
- A screen reader names an item's read button for what it does, Mark read or Mark unread, not "●" (#300).
|
||||
- The player's position slider has a name, and a screen reader reads where you are as a time, "12:30 of 45:00" (#301).
|
||||
- Placeholder text is in the theme's own hint colour, readable in every theme, and the search and feed filter boxes have names of their own (#302).
|
||||
- A screen reader reads toasts out, and an error stays until you dismiss it with its x; no more than four show at once (#303).
|
||||
- Error messages say what to do, for a server error, a lost connection, a file that would not play or a download that failed, in place of the browser's or the server's own words (#304).
|
||||
- Text boxes, selects and switches have an edge you can see in every theme, and a box with the keyboard in it has the same ring as every other control (#305, #308).
|
||||
- Dialogs are dialogs to a screen reader, named by their heading. The page behind one is out of reach while it is open, and closing it puts the keyboard back where it was (#306).
|
||||
- The boxes in Add a feed, Settings, Download latest and OPML are named by their labels, and clicking a label puts the cursor in its box (#307).
|
||||
- With reduced motion on, a check for new items still shows: the refresh button pulses and says "Checking for new items", spinners pulse, and toasts and sheets fade in (#310).
|
||||
- On a phone, a tapped button, feed or Directory category no longer stays shaded or fanned out as if it were selected (#311).
|
||||
- Scrolling to the end of a dialog or the phone's feed list no longer scrolls the page behind it (#312).
|
||||
- Cover artwork has a faint edge, so a dark cover stands off a dark theme (#313).
|
||||
- The tab bar's corners follow the tabs inside it (#314), and the Directory's covers fan out smoothly (#315).
|
||||
- A feed's page drops an error once a later check succeeds, and its item count and when it was last checked stay current, instead of keeping what they were when you opened it (#316).
|
||||
- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309).
|
||||
|
||||
### Security
|
||||
|
||||
- Open the original, an episode's View in a new tab, the Directory's item links and the `v` key follow a link only when it is a web address. A podcast's `javascript:` or `data:` link was followed as it was (#326).
|
||||
- `TRUSTED_HEADER` names ipx's `trusted_header` when it is not Cloudflare's, and ipx-web drops that header from anyone but `TUNNEL_PEER` too. Only Cloudflare's were dropped, so with another header anyone could sign in as anyone (#323).
|
||||
|
||||
## [1.2.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- A search box has an x at its right end once something is typed in it, which clears it: items, the feed list, the log in Admin and a Signal's icons (#291).
|
||||
- A feed's, an OPML subscription's or a Patreon creator's settings have In All Subscriptions: off keeps its items out of All Subscriptions and its unread count, still there on its own page, in Signals and in Currently Listening (#288).
|
||||
- A feed's settings have a Signals switch beside All Subscriptions, to keep its items out of every Signal (#289).
|
||||
- An item sent as both HTML and Markdown, as Scripting News's are, has a choice beside its buttons to show it as the publisher's HTML or as the Markdown, kept from one item to the next (#285).
|
||||
- A show inside an OPML subscription has a Keep button, a plus, on its page, so it stays on your list as a show of its own if the OPML drops it or you unsubscribe from the OPML (#280).
|
||||
- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271).
|
||||
- Admin, Server: how many days back a new Signal reads (#276).
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed's settings are in parts, each named beside its controls: Address, Downloads, Playback, Where it shows and Directory, with All Subscriptions and Signals drawn as the sidebar draws them, and DeadAir's pause as a slider without a heading (#290).
|
||||
- An OPML subscription's page lists the items of all its shows together, newest first, with the same tabs, search and sort as a show's, in place of a list of the shows; the shows are still in the folder in the feed list (#283).
|
||||
- Unless the server pays for them, Signals are turned on in Settings with your own TypeSafe key, and the Signals folder shows only once they are on (#271).
|
||||
- A Signal's dialog says how far back it reads, and that an edit reads again only what was picked out (#276).
|
||||
- Each request's span in Tempo is named for its method and path, such as `GET /api/entries`, instead of `ipx-web` for every one (#286).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Another person's download, its progress and its failure, no longer shows on your page (#294).
|
||||
|
||||
## [1.1.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
@@ -62,7 +113,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.1.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.3.0...main
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.2.0...web-v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.1.0...web-v1.2.0
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...web-v1.1.0
|
||||
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
|
||||
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
|
||||
(tracing-on) {
|
||||
tracing {
|
||||
span ipx-web
|
||||
# Named for the request, as ipx names its own (#286): one name for all left every span in
|
||||
# Tempo looking the same.
|
||||
span "{http.request.method} {http.request.uri.path}"
|
||||
}
|
||||
}
|
||||
(tracing-off) {
|
||||
@@ -76,6 +78,9 @@ http://{$WEB_BIND::8080} {
|
||||
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
|
||||
request_header @forged -Cf-Access-Authenticated-User-Email
|
||||
request_header @forged -Cf-Access-Jwt-Assertion
|
||||
# Whatever header ipx's [web] trusted_header names, when it is not Cloudflare's: ipx trusts it
|
||||
# from this address, and anyone could send it through here (#323).
|
||||
request_header @forged -{$TRUSTED_HEADER:Cf-Access-Authenticated-User-Email}
|
||||
|
||||
@api path /api/* /media/*
|
||||
handle @api {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<section id="directory" hidden></section>
|
||||
<section id="log" hidden></section>
|
||||
</main>
|
||||
<div id="toasts"></div>
|
||||
<div id="toasts" role="status"></div>
|
||||
|
||||
<script data-src="web/src"></script>
|
||||
</body>
|
||||
|
||||
197
web/app.css
197
web/app.css
@@ -20,6 +20,12 @@
|
||||
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
|
||||
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
|
||||
--shadow:0 8px 28px rgba(4,10,20,.55);
|
||||
/* The edge of a text box or a switch: the theme's own text mixed into its ground, as far as it
|
||||
takes to clear 3:1 on every surface one sits on (#305, #308). --line is for rules between
|
||||
things, which need not, and a box drawn in it all but vanished. A theme whose grounds sit
|
||||
further apart takes more; tests/contrast.js holds every palette to it. */
|
||||
--fieldmix:60%;
|
||||
--field:color-mix(in oklab,var(--fg) var(--fieldmix),var(--bg));
|
||||
}
|
||||
/* 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
|
||||
@@ -132,6 +138,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.5);
|
||||
}
|
||||
:root[data-theme="adwaita"][data-mode="light"] {
|
||||
--fieldmix:65%;
|
||||
--bg:#ffffff;
|
||||
--panel:#ebebed;
|
||||
--panel2:#fafafb;
|
||||
@@ -206,6 +213,7 @@
|
||||
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
|
||||
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
|
||||
:root[data-theme="nordic"] {
|
||||
--fieldmix:65%;
|
||||
--bg:#2e3440; /* nord0 */
|
||||
--panel:#3b4252; /* nord1 */
|
||||
--panel2:#434c5e; /* nord2 */
|
||||
@@ -243,6 +251,7 @@
|
||||
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"] {
|
||||
--fieldmix:70%;
|
||||
--bg:#1e1e2e; /* base */
|
||||
--panel:#181825; /* mantle */
|
||||
--panel2:#313244; /* surface0 */
|
||||
@@ -260,6 +269,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||
--fieldmix:80%;
|
||||
--bg:#eff1f5; /* base */
|
||||
--panel:#e6e9ef; /* mantle */
|
||||
--panel2:#dce0e8; /* crust */
|
||||
@@ -296,6 +306,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||
--fieldmix:70%;
|
||||
--bg:#fbf1c7; /* bg0 */
|
||||
--panel:#f2e5bc; /* bg0_s */
|
||||
--panel2:#ebdbb2; /* bg1 */
|
||||
@@ -332,6 +343,7 @@
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="solarized"][data-mode="light"] {
|
||||
--fieldmix:65%;
|
||||
--bg:#fdf6e3; /* base3 */
|
||||
--panel:#eee8d5; /* base2 */
|
||||
--panel2:#e6dfca;
|
||||
@@ -483,7 +495,15 @@ a{color:var(--accent)}
|
||||
.tgroup button:focus-visible,.tgroup a:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
*,*::before,*::after{animation:none!important;transition:none!important}
|
||||
/* Movement goes, not what it said: a still ring read as nothing happening (#310). Spinners pulse
|
||||
instead of turning, and toasts and sheets fade in instead of sliding. */
|
||||
.dlink.busy::after,.feed.scanning::after,.listload::before,#pullspin::before,
|
||||
body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,body.scan-any #scanAll .i{
|
||||
animation:ipxpulse .8s ease-in-out infinite alternate!important}
|
||||
.toast,.card{animation:ipxfade .2s!important}
|
||||
}
|
||||
@keyframes ipxpulse{to{opacity:.3}}
|
||||
@keyframes ipxfade{from{opacity:0}}
|
||||
::-webkit-scrollbar{width:10px;height:10px}
|
||||
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
@@ -511,7 +531,7 @@ a{color:var(--accent)}
|
||||
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
||||
color:var(--dim);flex:none;
|
||||
}
|
||||
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||
@media (hover:hover){.iconbtn:hover{background:var(--raise);color:var(--fg)}}
|
||||
/* 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
|
||||
@@ -537,18 +557,18 @@ a{color:var(--accent)}
|
||||
position:sticky;top:0;
|
||||
}
|
||||
#topbar .grow{flex:1}
|
||||
#topbar #epSearch{width:260px;flex:none}
|
||||
#topbar #epBox{width:260px;flex:none}
|
||||
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
|
||||
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
|
||||
.tgroup a{display:inline-grid;place-items:center}
|
||||
.tgroup button+button,.tgroup button+a{border-left:1px solid var(--line)}
|
||||
.tgroup button:hover:not(:disabled),.tgroup a:hover{background:var(--raise);color:var(--fg)}
|
||||
@media (hover:hover){.tgroup button:hover:not(:disabled),.tgroup a:hover{background:var(--raise);color:var(--fg)}}
|
||||
.tgroup button:disabled{opacity:.35;cursor:default}
|
||||
.sidetools button,.sidefoot button{
|
||||
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||
padding:6px 8px;font-size:12.5px;color:var(--dim);
|
||||
}
|
||||
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
|
||||
@media (hover:hover){.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}}
|
||||
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
|
||||
.sidefoot{
|
||||
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
|
||||
@@ -558,23 +578,36 @@ a{color:var(--accent)}
|
||||
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
|
||||
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
|
||||
.who button:hover{color:var(--fg)}
|
||||
@media (hover:hover){.who button:hover{color:var(--fg)}}
|
||||
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||
.sidefoot i{font-style:normal;opacity:.75}
|
||||
.searchwrap{padding:12px 12px 8px}
|
||||
/* The browser's own grey fell short of AA in most themes, and a placeholder is all some boxes say (#302). */
|
||||
::placeholder{color:var(--faint);opacity:1}
|
||||
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(--field);color:var(--fg);
|
||||
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
||||
}
|
||||
input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
input:focus,select:focus{border-color:var(--accent)}
|
||||
/* A search box and the x that clears it, inside its right end, there only once something is typed
|
||||
(#291). The browser's own x, in some browsers and not others, gives way to it. */
|
||||
.clearable{position:relative;display:flex;align-items:center;min-width:0}
|
||||
/* With [type=search], or the phone's padding for every text box, later on, takes the room back. */
|
||||
.clearable>input[type=search]{flex:1;min-width:0;padding-right:32px}
|
||||
.clearable>input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}
|
||||
.clearx{position:absolute;right:4px;top:50%;transform:translateY(-50%);display:grid;place-items:center;width:26px;height:26px;padding:0;border-radius:50%;color:var(--faint)}
|
||||
@media (hover:hover){.clearx:hover{color:var(--fg)}}
|
||||
.clearx svg{width:11px;height:11px}
|
||||
.clearable:has(>input:placeholder-shown) .clearx{display:none}
|
||||
.inline .clearable{flex:1}
|
||||
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
|
||||
feeds still line up with the places above. */
|
||||
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
|
||||
#feedlist{overflow-y:auto;overscroll-behavior:contain;padding:0 8px 12px 16px;flex:1;min-height:0}
|
||||
.feed{
|
||||
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
||||
cursor:pointer;margin-bottom:1px;position:relative;
|
||||
}
|
||||
.feed:hover{background:var(--panel2)}
|
||||
@media (hover:hover){.feed:hover{background:var(--panel2)}}
|
||||
.feed.sel{background:var(--raise)}
|
||||
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
||||
.feed.child{margin-left:11px}
|
||||
@@ -603,7 +636,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
max-height:220px;overflow-y:auto;margin-top:8px;
|
||||
}
|
||||
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
|
||||
.iconcell:hover,.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
|
||||
.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
|
||||
@media (hover:hover){.iconcell:hover{background:var(--raise);color:var(--fg)}}
|
||||
.iconcell .ficon{width:20px;height:20px}
|
||||
/* Auto in the icon picker, while an icon is picked: the scan spinner, beside the word. */
|
||||
.dlink.busy::after{
|
||||
@@ -617,7 +651,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
|
||||
border-radius:4px;color:var(--faint);
|
||||
}
|
||||
.chev:hover{color:var(--fg)}
|
||||
@media (hover:hover){.chev:hover{color:var(--fg)}}
|
||||
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
||||
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
|
||||
.feed.scanning .badge{display:none}
|
||||
@@ -647,7 +681,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
|
||||
border-radius:9px;cursor:pointer;background:var(--panel);
|
||||
}
|
||||
.childrow:hover{border-color:var(--accent)}
|
||||
@media (hover:hover){.childrow:hover{border-color:var(--accent)}}
|
||||
.childrow .art{width:32px;height:32px;font-size:12px}
|
||||
.childrow .txt{flex:1;min-width:0}
|
||||
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
@@ -675,7 +709,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
.dhead{margin-bottom:6px}
|
||||
.dback{display:inline-flex;align-items:center;gap:5px;margin:0 0 8px -5px;padding:3px 5px;border-radius:6px;font-size:13px;color:var(--accent)}
|
||||
.dback .i{width:11px;height:11px}
|
||||
.dback:hover{background:var(--raise)}
|
||||
@media (hover:hover){.dback:hover{background:var(--raise)}}
|
||||
.dtitle{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px}
|
||||
.dtitle h2{margin:0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
|
||||
.dhead .sub{margin:5px 0 0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
|
||||
@@ -683,7 +717,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
.dsech{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}
|
||||
.dsech h3{margin:0;font-size:19px;line-height:1.25;font-weight:650;letter-spacing:-.01em}
|
||||
.dall{font-size:13px;color:var(--accent);flex:none}
|
||||
.dall:hover{text-decoration:underline}
|
||||
@media (hover:hover){.dall:hover{text-decoration:underline}}
|
||||
/* The most subscribed, numbered because they are ranked: down one column, then the next. */
|
||||
.chart{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(var(--rows,5),auto);grid-auto-flow:column;column-gap:32px}
|
||||
.rank{display:grid;grid-template-columns:22px 52px minmax(0,1fr) auto;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)}
|
||||
@@ -701,7 +735,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
display:flex;flex-direction:column;align-items:flex-start;
|
||||
background:var(--panel);border:1px solid var(--line);
|
||||
}
|
||||
.cat:hover{border-color:var(--accent)}
|
||||
@media (hover:hover){.cat:hover{border-color:var(--accent)}}
|
||||
/* The whole width: the fan sits below two lines of name, and a narrower name broke Government
|
||||
in two in the wider faces, Classic's and Glass's. */
|
||||
.cat b{font-size:16px;line-height:1.2;font-weight:650;color:var(--fg);overflow-wrap:break-word}
|
||||
@@ -713,22 +747,22 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
to open, the browser drew the covers again on the first frames, and again on the way back,
|
||||
and the fan opened in a jerk (#132). All three move, for as long as the eye can follow, as
|
||||
one motion that settles rather than stops. */
|
||||
will-change:transform;
|
||||
will-change:rotate,translate;
|
||||
transition:rotate .45s cubic-bezier(.2,.7,.2,1),translate .45s cubic-bezier(.2,.7,.2,1);
|
||||
}
|
||||
.cat .fan .art:nth-last-child(1){right:0;rotate:8deg;translate:0 -12px}
|
||||
.cat .fan .art:nth-last-child(2){right:25px;rotate:-3deg;translate:0 -4px}
|
||||
.cat .fan .art:nth-last-child(3){right:50px;rotate:-13deg;translate:0 4px}
|
||||
.cat:hover .fan .art:nth-last-child(1){rotate:13deg;translate:5px -17px}
|
||||
.cat:hover .fan .art:nth-last-child(2){rotate:-2deg;translate:0 -8px}
|
||||
.cat:hover .fan .art:nth-last-child(3){rotate:-19deg;translate:-7px 3px}
|
||||
@media (hover:hover){.cat:hover .fan .art:nth-last-child(1){rotate:13deg;translate:5px -17px}}
|
||||
@media (hover:hover){.cat:hover .fan .art:nth-last-child(2){rotate:-2deg;translate:0 -8px}}
|
||||
@media (hover:hover){.cat:hover .fan .art:nth-last-child(3){rotate:-19deg;translate:-7px 3px}}
|
||||
/* A category's subcategories, each with how many it holds, then the order. A picked one is
|
||||
drawn as a picked tab is. */
|
||||
.dtools{display:flex;align-items:center;gap:10px 14px;margin-bottom:14px}
|
||||
.pills{display:flex;flex-wrap:wrap;gap:6px;flex:1 1 0;min-width:0}
|
||||
.pills button{flex:none;padding:4px 11px;border:1px solid var(--line);border-radius:999px;font-size:13px;color:var(--dim);white-space:nowrap}
|
||||
.pills button small{margin-left:6px;color:var(--faint);font-size:11.5px;font-variant-numeric:tabular-nums}
|
||||
.pills button:hover{border-color:var(--accent);color:var(--fg)}
|
||||
@media (hover:hover){.pills button:hover{border-color:var(--accent);color:var(--fg)}}
|
||||
.pills button.on{background:var(--raise);border-color:var(--raise);color:var(--fg);font-weight:500}
|
||||
.dtools .grow{flex:1}
|
||||
#dirsort{flex:none;width:auto;padding:4px 6px;font-size:13px}
|
||||
@@ -742,7 +776,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||||
/* Every one opens: yours its feed, the rest their page here. */
|
||||
.tile,.rank{cursor:pointer}
|
||||
.tile:hover .txt b,.rank:hover .txt b{color:var(--accent)}
|
||||
@media (hover:hover){.tile:hover .txt b,.rank:hover .txt b{color:var(--accent)}}
|
||||
/* A feed's page before you subscribe, as Apple's show page: the cover large, what it is and who
|
||||
here reads it beside it, then what it has published lately. */
|
||||
.show{display:flex;gap:22px;align-items:flex-end;margin-top:4px}
|
||||
@@ -753,7 +787,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
.show .dfail{color:var(--bad)}
|
||||
.show .acts{margin-top:8px}
|
||||
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left}
|
||||
.dlink:hover{text-decoration:underline}
|
||||
@media (hover:hover){.dlink:hover{text-decoration:underline}}
|
||||
/* What the feed says it is, three lines of it until More. */
|
||||
.about{max-width:760px;margin-top:18px}
|
||||
.about p{margin:0;color:var(--dim);font-size:14px;line-height:1.55;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;overflow-wrap:anywhere}
|
||||
@@ -763,7 +797,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
.lep{padding:12px 0;border-bottom:1px solid var(--line)}
|
||||
.lep b{display:block;font-size:15px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||
.lep b a{color:var(--fg);text-decoration:none}
|
||||
.lep b a:hover{color:var(--accent)}
|
||||
@media (hover:hover){.lep b a:hover{color:var(--accent)}}
|
||||
.lep p{margin:4px 0 0;color:var(--dim);font-size:13px;line-height:1.5;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}
|
||||
.lep small{display:flex;align-items:center;gap:7px;margin-top:6px;color:var(--faint);font-size:12px;font-variant-numeric:tabular-nums}
|
||||
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
|
||||
@@ -777,6 +811,10 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
|
||||
}
|
||||
.art>.tile{width:100%;height:100%;display:block}
|
||||
/* A hairline inside the edge, so a dark cover keeps its boundary on a dark page (#313). Black or
|
||||
white, not a palette colour, which shows as a tinted fringe; initials are not a picture. */
|
||||
.art:not(.ini){outline:1px solid oklch(1 0 0/.1);outline-offset:-1px}
|
||||
:root[data-mode="light"] .art:not(.ini){outline-color:oklch(0 0 0/.1)}
|
||||
#pdead .tile{width:26px;height:26px;border-radius:6px}
|
||||
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
||||
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
||||
@@ -840,7 +878,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
#files .empty{padding:24px 0}
|
||||
.fhd{font-size:12px;color:var(--faint)}
|
||||
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
|
||||
#grab:hover{background:var(--accent)}
|
||||
@media (hover:hover){#grab:hover{background:var(--accent)}}
|
||||
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
|
||||
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
||||
@@ -848,6 +886,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
/* The reader's buttons go as one: as items of the line itself, a long feed name took them onto
|
||||
the next line one at a time (#146). */
|
||||
.dacts{display:flex;gap:inherit;flex:none}
|
||||
.dmeta .tview{width:auto;padding:3px 6px;font-size:12px}
|
||||
#detail .encbox{margin:0 0 10px}
|
||||
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
||||
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
||||
@@ -863,20 +902,20 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2;overflow-wrap:anywhere}
|
||||
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
|
||||
.fhead .sub a{color:var(--dim);text-decoration:none}
|
||||
.fhead .sub a:hover{color:var(--accent)}
|
||||
@media (hover:hover){.fhead .sub a:hover{color:var(--accent)}}
|
||||
.acts{display:flex;gap:7px;align-items:center;margin-top:auto}
|
||||
.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:hover{background:var(--raise)}
|
||||
@media (hover:hover){.acts .btn:hover{background:var(--raise)}}
|
||||
.btn{
|
||||
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||
padding:6px 12px;font-size:13px;
|
||||
}
|
||||
.btn:hover{border-color:var(--accent)}
|
||||
@media (hover:hover){.btn:hover{border-color:var(--accent)}}
|
||||
a.btn{text-decoration:none;color:inherit}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
|
||||
.btn.primary:hover{filter:brightness(1.08)}
|
||||
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
|
||||
@media (hover:hover){.btn.primary:hover{filter:brightness(1.08)}}
|
||||
@media (hover:hover){.btn.danger:hover{border-color:var(--bad);color:var(--bad)}}
|
||||
/* An icon in place of a word; the word is in its tooltip. */
|
||||
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
|
||||
/* Inline in a sentence, next to a plain-word error explanation. */
|
||||
@@ -901,7 +940,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
|
||||
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
|
||||
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
|
||||
}
|
||||
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
|
||||
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:10px;padding:3px}
|
||||
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
|
||||
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
|
||||
.tabs a{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim);text-decoration:none}
|
||||
@@ -941,7 +980,8 @@ body.adminpage #log{max-width:none}
|
||||
/* Each heading sorts by its column; the caret says which way. */
|
||||
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
|
||||
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
|
||||
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
|
||||
.ephead .hs.on{color:var(--fg)}
|
||||
@media (hover:hover){.ephead .hs:hover{color:var(--fg)}}
|
||||
.ephead .hs .arr{display:inline-flex}
|
||||
.ephead .hs .arr .i{width:8px;height:8px}
|
||||
.ephead .hs .arr.asc{transform:rotate(-90deg)}
|
||||
@@ -953,7 +993,7 @@ body.adminpage #log{max-width:none}
|
||||
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
|
||||
.ep>*{min-width:0}
|
||||
.ep.sel{background:var(--raise);border-color:var(--line)}
|
||||
.ep:hover{background:var(--panel)}
|
||||
@media (hover:hover){.ep:hover{background:var(--panel)}}
|
||||
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
|
||||
primary action and links, so a count no longer looks like a button. */
|
||||
/* No padding: a button keeps the browser's side padding, which left too little room for the 16px
|
||||
@@ -979,8 +1019,8 @@ body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
|
||||
body.playing .eq i:nth-child(1){animation-delay:-.3s}
|
||||
body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
@keyframes eq{from{transform:scaleY(.35)}}
|
||||
.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}
|
||||
@media (hover:hover){.ep .st:hover,.ep .fl:hover{background:var(--raise)}}
|
||||
.ep .t{font-weight:600;font-size:13.5px;display:block;width:100%;padding:0;text-align:start;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}
|
||||
@@ -997,7 +1037,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
#split.one .h-fd,#split.one .ep .fd{display:none}
|
||||
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
||||
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
||||
.ep:hover .rowacts{opacity:1}
|
||||
/* Focus shows them as hover does: Tab reached Play and Delete while they were still invisible (#299). */
|
||||
.ep:hover .rowacts,.ep:focus-within .rowacts{opacity:1}
|
||||
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
|
||||
.dlbar.live{background:var(--raise)}
|
||||
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
|
||||
@@ -1035,7 +1076,7 @@ body.has-video #audio{
|
||||
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
|
||||
border-radius:50%;
|
||||
}
|
||||
#pplay:hover{background:var(--accent);color:var(--ink)}
|
||||
@media (hover:hover){#pplay:hover{background:var(--accent);color:var(--ink)}}
|
||||
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
|
||||
input[type=range]{
|
||||
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
|
||||
@@ -1055,9 +1096,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
place-items:center;z-index:50;padding:20px;
|
||||
}
|
||||
#modal.on{display:grid}
|
||||
/* Focus lands on the card itself as a dialog opens (#306): a place to start from, not a control. */
|
||||
#modalCard:focus{outline:none}
|
||||
.card{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
||||
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
|
||||
overscroll-behavior:contain;
|
||||
}
|
||||
.card.wide{width:min(1000px,100%)}
|
||||
#logbox{
|
||||
@@ -1094,14 +1138,30 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
/* ---------- a feed's settings (#167) ---------- */
|
||||
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
|
||||
three screens down. */
|
||||
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)}
|
||||
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
|
||||
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size}
|
||||
.fsetbody{overflow:auto;overscroll-behavior:contain;min-height:0;padding:22px 24px 24px}
|
||||
.card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)}
|
||||
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:18px}
|
||||
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:20px}
|
||||
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
|
||||
.fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||
.faddr{margin-bottom:18px}
|
||||
/* A part of the settings, named in a column of its own beside its controls (#290); on a phone the
|
||||
name sits above them. Ruled apart rather than boxed: the boxes, one round downloads and none
|
||||
round the rest, read as a difference between the settings that was not there. */
|
||||
.fsec{display:grid;grid-template-columns:104px minmax(0,1fr);gap:6px 20px;padding:14px 0 16px;border-top:1px solid var(--line)}
|
||||
.fsec h4{margin:0;padding-top:10px;font-size:13px;font-weight:600;line-height:1.3;color:var(--dim)}
|
||||
.fsecin{display:grid;gap:6px;min-width:0}
|
||||
@container (max-width:480px){
|
||||
.fsec{grid-template-columns:minmax(0,1fr);gap:4px}
|
||||
.fsec h4{padding-top:0}
|
||||
}
|
||||
/* All Subscriptions and Signals with the icons the sidebar gives them, so the switch reads as that place. */
|
||||
.switch.fplace>span:first-child{display:flex;align-items:center;gap:10px}
|
||||
.fico{display:inline-flex;justify-content:center;width:18px;flex:none;color:var(--accent)}
|
||||
.fico svg{width:15px;height:15px}
|
||||
.fsig img{display:block;width:18px;height:18px;border-radius:5px}
|
||||
/* Set on the OPML it came from, not here: shown, and plainly out of reach. */
|
||||
.fplace:has(input:disabled){opacity:.5;cursor:default}
|
||||
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
|
||||
/* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control,
|
||||
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
|
||||
@@ -1110,22 +1170,22 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.switch.lead{font-weight:600}
|
||||
.sw{position:relative;flex:none;width:42px;height:26px}
|
||||
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
|
||||
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
|
||||
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--field);pointer-events:none;transition:background-color .18s,border-color .18s}
|
||||
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
|
||||
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
|
||||
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
|
||||
/* White on a light track did not show where the knob was, or so whether it was on (#308). */
|
||||
:root[data-mode="light"] .sw i::after{box-shadow:0 0 0 1px var(--field),0 1px 3px rgba(0,0,0,.35)}
|
||||
.sw input:checked+i::after{transform:translateX(16px)}
|
||||
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
||||
/* The switch and what only matters while it downloads on its own, in one box; the rest folds away
|
||||
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
|
||||
rows in a grid of one row, 1fr open and 0fr shut, so the fold has a height to move. */
|
||||
.fdl{border:1px solid var(--line);border-radius:12px;padding:4px 14px}
|
||||
/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
|
||||
one row, 1fr open and 0fr shut, so the fold has a height to move. */
|
||||
/* The same box round a part of a dialog that stands apart: API tokens in Settings. */
|
||||
.field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||||
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
|
||||
.fdeps.off{grid-template-rows:0fr}
|
||||
.fdeps>div{min-height:0;overflow:hidden}
|
||||
.fdepsin{padding:6px 2px 10px}
|
||||
.fdepsin{padding:6px 0 2px}
|
||||
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
|
||||
.seg{margin-bottom:12px}
|
||||
.seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer}
|
||||
@@ -1145,9 +1205,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
|
||||
.wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)}
|
||||
.ffield{display:grid;gap:6px;margin-bottom:14px}
|
||||
.fsecin>.ffield:last-child{margin-bottom:0}
|
||||
.fsecin>.cap+.ffield{margin-top:10px}
|
||||
/* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */
|
||||
.dpause{display:grid;gap:6px;margin-top:6px}
|
||||
.dphead{display:flex;justify-content:space-between;align-items:baseline}
|
||||
/* The pause it leaves, over the slider's right end; the slider is named for a screen reader alone. */
|
||||
.dphead{display:flex;justify-content:flex-end;align-items:baseline}
|
||||
.dphead output{font-variant-numeric:tabular-nums;font-weight:600}
|
||||
.dpslide{--t:22px;position:relative;height:var(--t)}
|
||||
.dpslide::before,.dpslide::after{content:'';position:absolute;top:50%;pointer-events:none}
|
||||
@@ -1164,17 +1227,14 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
|
||||
.dpause:has(input:disabled){opacity:.45}
|
||||
.dpslide input[type=range]:disabled{cursor:default}
|
||||
.fdl+.ffield{margin-top:18px}
|
||||
/* DeadAir shares the box but not the fold: it applies whether or not this show downloads on its own. */
|
||||
.fdead{border-top:1px solid var(--line);padding-top:10px;margin-top:4px}
|
||||
.flabel{font-size:14px;font-weight:500}
|
||||
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--field);border-radius:9px;background:var(--bg);cursor:text}
|
||||
.chips:focus-within{border-color:var(--accent)}
|
||||
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
|
||||
.chipx:hover{background:var(--raise);color:var(--fg)}
|
||||
@media (hover:hover){.chipx:hover{background:var(--raise);color:var(--fg)}}
|
||||
.chipx .i{width:9px;height:9px}
|
||||
.chips .chipin{flex:1 0 8ch;width:auto;min-width:0;padding:4px 6px;border:0;background:none}
|
||||
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
||||
@@ -1183,7 +1243,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||
}
|
||||
/* 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)}
|
||||
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel);display:flex;gap:10px;align-items:flex-start}
|
||||
.toastx{flex:none;display:grid;place-items:center;width:24px;height:24px;margin:-3px -7px -3px 0;border-radius:6px;color:inherit}
|
||||
.toastx svg{width:11px;height:11px}
|
||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||
#burger,#dback{display:none}
|
||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||
@@ -1209,7 +1271,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
/* Places above the feeds: not subscriptions, but where to look. */
|
||||
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
|
||||
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
|
||||
.place:hover{background:var(--panel2)}
|
||||
@media (hover:hover){.place:hover{background:var(--panel2)}}
|
||||
.place.sel{background:var(--raise)}
|
||||
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
|
||||
.place b{flex:1;font-weight:500;font-size:13.5px}
|
||||
@@ -1236,7 +1298,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
|
||||
}
|
||||
#topbar .grow,.tgroup.item{display:none}
|
||||
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
||||
#topbar #epBox{width:auto;flex:1;min-width:0}
|
||||
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
||||
.fhead.slim .acts{flex:1 1 100%}
|
||||
.iconbtn{width:38px;height:38px}
|
||||
@@ -1327,10 +1389,8 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
#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. */
|
||||
/* Buttons: filled and round. */
|
||||
.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}
|
||||
@@ -1342,9 +1402,9 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
|
||||
/* 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;
|
||||
font-size:17px;background:var(--panel2);border-color:var(--field);border-radius:10px;padding:9px 12px;
|
||||
}
|
||||
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 14px}
|
||||
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 34px 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. */
|
||||
@@ -1367,7 +1427,6 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
/* 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}
|
||||
@@ -1397,6 +1456,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
.fsetbody{padding:20px 16px 24px}
|
||||
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
|
||||
.fsethead .art{width:64px;height:64px;border-radius:14px}
|
||||
.fsec h4{font-size:14px}
|
||||
.fsethead p,.wcap,.cap{font-size:14px}
|
||||
.switch,.flabel{font-size:17px}
|
||||
.switch{min-height:44px}
|
||||
@@ -1409,10 +1469,10 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
.dpends{font-size:14px}
|
||||
/* 16px or more, or Safari zooms the page in on it. */
|
||||
.wcap input[type=number]{font-size:16px;padding:3px 4px}
|
||||
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
|
||||
.chips{background:var(--panel2);border-color:var(--field);border-radius:10px}
|
||||
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
|
||||
.chipx{width:26px;height:26px}
|
||||
.fdl,.field.boxed{border-radius:14px}
|
||||
.field.boxed{border-radius:14px}
|
||||
.toast{border-radius:14px}
|
||||
}
|
||||
@keyframes sheet{from{transform:translateY(100%)}}
|
||||
@@ -1438,9 +1498,11 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="classic"] .btn,
|
||||
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
|
||||
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
|
||||
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
|
||||
:root[data-theme="classic"] .tgroup a:hover,
|
||||
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
|
||||
@media (hover:hover){
|
||||
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
|
||||
:root[data-theme="classic"] .tgroup a:hover,
|
||||
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
|
||||
}
|
||||
:root[data-theme="classic"] .btn.primary,
|
||||
:root[data-theme="classic"] .tabs button.on,
|
||||
:root[data-theme="classic"] .feed.sel,
|
||||
@@ -1475,7 +1537,8 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="classic"] .ep.sel .fl,
|
||||
:root[data-theme="classic"] .ep.sel .file,
|
||||
:root[data-theme="classic"] .ep.sel .kind,
|
||||
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
|
||||
:root[data-theme="classic"] .ep.sel .iconbtn{color:#fff}
|
||||
@media (hover:hover){:root[data-theme="classic"] .ep.sel .iconbtn:hover{color:var(--fg)}}
|
||||
/* 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.bad{color:#ffb8ad}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
<input type="search" id="epSearch" placeholder="Search items…">
|
||||
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…" aria-label="Search"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span>
|
||||
<div class="tgroup" id="admintools">
|
||||
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
|
||||
<a id="admin" href="/admin" title="Admin: the server's settings, accounts and the log" aria-label="Admin" data-icon="admin"></a>
|
||||
@@ -36,7 +36,7 @@
|
||||
</header>
|
||||
<div id="shell">
|
||||
<aside id="sidebar">
|
||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
||||
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…" aria-label="Filter feeds"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></div>
|
||||
<div id="feedlist"></div>
|
||||
<div class="sidefoot">
|
||||
<div class="who"><span id="who"></span><button id="signout" title="Sign out" aria-label="Sign out" data-icon="signout"></button></div>
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
<div id="seekrow">
|
||||
<span id="pcur">0:00</span>
|
||||
<input type="range" id="seek" min="0" max="1000" value="0">
|
||||
<input type="range" id="seek" min="0" max="1000" value="0" aria-label="Position">
|
||||
<span id="pdur">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,8 +78,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal"><div class="card" id="modalCard"></div></div>
|
||||
<div id="toasts"></div>
|
||||
<div id="modal"><div class="card" id="modalCard" role="dialog" aria-modal="true" tabindex="-1"></div></div>
|
||||
<div id="toasts" role="status"></div>
|
||||
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
|
||||
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
|
||||
the current file is video -- see body.has-video below. -->
|
||||
|
||||
@@ -53,6 +53,16 @@ async function drawServer(){
|
||||
<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 class="check"><input type="checkbox" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>
|
||||
Signals for everyone, on this server's TypeSafe key</label>
|
||||
<span class="hint">${g.typesafe_server_key
|
||||
? 'On, everyone has Signals and every item read for one is a call on this server\'s key. Off, each person turns Signals on in their own Settings with a key of their own.'
|
||||
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</span></div>
|
||||
<div class="field"><label for="gsigdays">Signals look back (days)</label>
|
||||
<input type="number" id="gsigdays" min="1" max="365" value="${g.signal_days}">
|
||||
<span class="hint">How far back a new Signal reads. Each item read is a call to TypeSafe, so a
|
||||
longer look back costs more, once for each new Signal. Editing a Signal reads again only what
|
||||
it had picked out.</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 primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||
@@ -64,7 +74,10 @@ async function drawServer(){
|
||||
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
||||
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
||||
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
||||
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
|
||||
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0),
|
||||
signal_days: Math.min(365, Math.max(1, Number($<HTMLInputElement>('#gsigdays').value) || 7)),
|
||||
// Left alone while the server has no key to offer, rather than switched off.
|
||||
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
|
||||
toast('Settings saved');
|
||||
}catch(e){ toast(why(e), true); }
|
||||
};
|
||||
@@ -249,7 +262,7 @@ function drawLogView(){
|
||||
<option value="WARN">Warnings and errors</option>
|
||||
<option value="ERROR">Errors only</option>
|
||||
</select>
|
||||
<input type="search" id="logq" class="grow" placeholder="Filter…">
|
||||
${searchBox('<input type="search" id="logq" placeholder="Filter…">','grow')}
|
||||
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
||||
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
/* ---------------- modals ---------------- */
|
||||
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
|
||||
// A dialog is a dialog to a screen reader, named by its heading, and the page behind it is out of
|
||||
// reach until it closes: Tab used to walk out of it into the toolbar, and closing it left focus
|
||||
// nowhere (#306). Not a <dialog>: its top layer would cover the toasts, and an error's Dismiss
|
||||
// with them.
|
||||
let modalFrom: HTMLElement|null=null, benched: HTMLElement[]=[];
|
||||
function openModal(html: string, wide?: boolean, cls?: string){
|
||||
$('#modalCard').innerHTML=html;
|
||||
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||
$('#modal').classList.add('on');
|
||||
const m=$('#modal'), card=$('#modalCard');
|
||||
card.innerHTML=html;
|
||||
card.className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||
const h=card.querySelector('h2,h3');
|
||||
if(h){ h.id=h.id||'modalTitle'; card.setAttribute('aria-labelledby',h.id); } else card.removeAttribute('aria-labelledby');
|
||||
if(!m.classList.contains('on')){
|
||||
modalFrom=document.activeElement as HTMLElement|null;
|
||||
// Not what is inert already (#dpeek), which must stay so.
|
||||
benched=([...document.body.children] as HTMLElement[]).filter(el=>el!==m&&el.id!=='toasts'&&!el.inert);
|
||||
for(const el of benched) el.inert=true;
|
||||
m.classList.add('on');
|
||||
}
|
||||
// Into the dialog; a dialog that wants the cursor in a box puts it there after this.
|
||||
if(!card.contains(document.activeElement)) card.focus();
|
||||
}
|
||||
function closeModal(){
|
||||
$('#modal').classList.remove('on');
|
||||
const m=$('#modal');
|
||||
if(!m.classList.contains('on')) return;
|
||||
m.classList.remove('on');
|
||||
for(const el of benched) el.inert=false;
|
||||
benched=[];
|
||||
if(modalFrom?.isConnected) modalFrom.focus();
|
||||
modalFrom=null;
|
||||
}
|
||||
|
||||
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
|
||||
|
||||
$<HTMLButtonElement>('#addFeed').onclick=()=>{
|
||||
openModal(`<h3>Add a feed</h3>
|
||||
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||
<div class="field"><label for="nurl">Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||
<div class="field"><label for="nkw">Keywords (optional, comma separated)</label>
|
||||
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
@@ -60,6 +82,7 @@ function listedFeed(p: DirFeed,cls: string,rank?: number){
|
||||
// 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>`
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||
asLink(el,false);
|
||||
// Yours has an id here as well as the directory's, and opens by it.
|
||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||
@@ -258,7 +281,7 @@ async function fillShow(box: HTMLElement,p: DirFeed){
|
||||
list.innerHTML=items.length?items.map(e=>{
|
||||
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
|
||||
const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean);
|
||||
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
|
||||
return `<article class="lep"><b>${webUrl(e.link)?`<a href="${esc(webUrl(e.link))}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
|
||||
+(text?`<p>${esc(text)}</p>`:'')
|
||||
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
|
||||
}).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>';
|
||||
@@ -418,10 +441,10 @@ async function prefsModal(){
|
||||
const g = await api('/api/settings');
|
||||
const admin = !!(S.me&&S.me.admin);
|
||||
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 for="stheme">Theme</label>
|
||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
||||
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label for="smode">Light or dark</label>
|
||||
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||
@@ -440,13 +463,18 @@ async function prefsModal(){
|
||||
<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 for="stskey">TypeSafe key</label>
|
||||
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
||||
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
||||
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||||
<span class="hint">Signals pick what matters out of everything you subscribe to, on your own
|
||||
key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.
|
||||
The key is kept on this server and never shown again.</span></div>
|
||||
${S.me?.signals==='server'?`<div class="field"><label>Signals</label>
|
||||
<span class="hint">Signals pick what matters out of everything you subscribe to. This server
|
||||
pays for them, so there is nothing to set up.</span></div>`:`<div class="field">
|
||||
<label class="check"><input type="checkbox" id="ssigon"${S.me?.typesafe_key_set?' checked':''}> Signals</label>
|
||||
<span class="hint">Signals pick what matters out of everything you subscribe to. To use Signals
|
||||
you will need an API key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.</span>
|
||||
<div id="ssigkey"${S.me?.typesafe_key_set?'':' hidden'}>
|
||||
<label for="stskey">TypeSafe key</label>
|
||||
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
||||
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
||||
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||||
<span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`}
|
||||
<div class="field boxed"><label for="tokname">API tokens</label>
|
||||
<div id="tokens"></div>
|
||||
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
||||
@@ -456,7 +484,8 @@ async function prefsModal(){
|
||||
Anyone holding one is you here, so revoke one you no longer use.</span></div>
|
||||
<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.
|
||||
${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"><span class="hint" id="sversion">${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}</span></div>`);
|
||||
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||||
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||||
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||
@@ -476,18 +505,31 @@ async function prefsModal(){
|
||||
$<HTMLInputElement>('#tokval').select();
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
const saveKey=async(typesafe_key: string|null)=>{
|
||||
try{
|
||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||||
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
|
||||
$<HTMLInputElement>('#stskey').value='';
|
||||
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||||
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||||
toast(typesafe_key?'Saved':'Forgotten');
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||||
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
|
||||
// Your own key, unless the server's is used for everyone (#271). Signals are on once TypeSafe
|
||||
// has taken a key, and off again when it is forgotten.
|
||||
if(S.me?.signals!=='server'){
|
||||
const saveKey=async(typesafe_key: string|null)=>{
|
||||
try{
|
||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||||
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
|
||||
$<HTMLInputElement>('#stskey').value='';
|
||||
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||||
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||||
$<HTMLInputElement>('#ssigon').checked=!!typesafe_key; $('#ssigkey').hidden=!typesafe_key;
|
||||
toast(typesafe_key?'Signals are on':'Signals are off');
|
||||
renderFeeds();
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
$<HTMLInputElement>('#ssigon').onchange=(e: Event)=>{
|
||||
const on=(e.target as HTMLInputElement).checked;
|
||||
if(on){ $('#ssigkey').hidden=false; $<HTMLInputElement>('#stskey').focus(); return; }
|
||||
if(!S.me?.typesafe_key_set){ $('#ssigkey').hidden=true; return; }
|
||||
if(confirm('Turn Signals off and forget your TypeSafe key? Your signals are kept for when you turn them on again.')) saveKey(null);
|
||||
else (e.target as HTMLInputElement).checked=true;
|
||||
};
|
||||
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||||
$<HTMLButtonElement>('#stsforget').onclick=()=>$<HTMLInputElement>('#ssigon').click();
|
||||
}
|
||||
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||||
const blocked=splitWords((e.target as HTMLInputElement).value);
|
||||
try{
|
||||
@@ -571,13 +613,23 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
|
||||
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
|
||||
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
||||
// Each part named in a column of its own, the controls beside it (#290): the dialog had grown one
|
||||
// captioned setting at a time into a single column three screens long on a phone.
|
||||
const sect=(title: string, body: string)=>`<section class="fsec"><h4>${title}</h4><div class="fsecin">${body}</div></section>`;
|
||||
const these=isGroup?'these shows':'this show';
|
||||
// The places in the sidebar this show's items go to, drawn as the sidebar draws them (#288, #289).
|
||||
const place=(id: string, key: 'hide_from_all'|'hide_from_signals', icon: string, title: string)=>{
|
||||
const byList=f.group?S.feeds.find(g=>g.id===f.group&&g[key]):undefined;
|
||||
return sw(`<input type="checkbox" role="switch" id="${id}"${shownIn(f,key)?' checked':''}${byList?' disabled':''}>`,
|
||||
`<span class="fico" aria-hidden="true">${icon}</span>${title}`,' fplace');
|
||||
};
|
||||
const listHides=f.group?S.feeds.find(g=>g.id===f.group&&(g.hide_from_all||g.hide_from_signals)):undefined;
|
||||
openModal(`<div class="fsetbody">
|
||||
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
||||
<div class="field faddr"><label for="surl">Address</label>
|
||||
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
|
||||
${sect('Address',`<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" aria-label="Address"${admin?'':' readonly'}>
|
||||
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
||||
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
|
||||
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
||||
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}`)}
|
||||
${sect('Downloads',`${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
||||
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
|
||||
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
|
||||
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
|
||||
@@ -588,27 +640,29 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
|
||||
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
|
||||
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
|
||||
</div></div></div>
|
||||
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
||||
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
|
||||
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
|
||||
are part of the telling.</p>
|
||||
</div></div></div>`)}
|
||||
${sect('Playback',`${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
||||
<p class="cap">Shrinks long silences in ${these} to a beat, so episodes finish sooner. Leave it off
|
||||
for stories whose pauses are part of the telling.</p>
|
||||
<div class="dpause">
|
||||
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
|
||||
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
|
||||
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div>
|
||||
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div>
|
||||
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
||||
with what Settings hides everywhere.</p></div>`}
|
||||
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||
<div class="dphead"><output id="sdpauseout" for="sdpause"></output></div>
|
||||
<div class="dpslide"><input type="range" id="sdpause" aria-label="Breathing room" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
|
||||
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div></div>`)}
|
||||
${sect('Where it shows',`<div class="fplaces">${place('sinall','hide_from_all',ICON.all,'All Subscriptions')}${
|
||||
signalsOn()?place('sinsig','hide_from_signals',`<span class="fsig">${tile('signals')}</span>`,'Signals'):''}</div>
|
||||
<p class="cap">${listHides
|
||||
?`Off where ${esc(listHides.title||listHides.id)} is off; its settings bring them back.`
|
||||
:`Off leaves ${these} out of that list and its count. ${isGroup?'Their':'Its'} own page still has everything.`}</p>
|
||||
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||
<p class="cap">Whole words or phrases, hidden everywhere and not downloaded for you.</p></div>`}`)}
|
||||
${sect('Directory',`${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
||||
<p class="cap">${f.private_by_address
|
||||
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
|
||||
:f.private&&!admin
|
||||
?'Never shared with the directory. Only an admin can share it again.'
|
||||
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
|
||||
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p>`)}
|
||||
</div>
|
||||
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
||||
@@ -649,7 +703,7 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
||||
};
|
||||
for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
||||
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
||||
// The pause slider only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
||||
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
|
||||
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
||||
pause.oninput=pauseText; pauseText();
|
||||
@@ -698,6 +752,11 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||
const priv=$<HTMLInputElement>('#sprivate');
|
||||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||
// Sent only when changed, and not while the OPML decides it.
|
||||
for(const [id,key] of [['#sinall','hide_from_all'],['#sinsig','hide_from_signals']] as const){
|
||||
const x=document.querySelector<HTMLInputElement>(id);
|
||||
if(x&&!x.disabled&&x.checked===!!f[key]) patch[key]=!x.checked;
|
||||
}
|
||||
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
||||
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
|
||||
@@ -710,7 +769,7 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
|
||||
function downloadLatestModal(f: Feed){
|
||||
openModal(`<h3>Download latest items</h3>
|
||||
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||
<div class="field"><label for="dcount">How many of the newest undownloaded items?</label>
|
||||
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
@@ -746,9 +805,9 @@ function opmlModal(){
|
||||
<div class="inline">
|
||||
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
||||
</div></div>
|
||||
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
|
||||
<div class="field" style="margin-top:16px"><label for="opmlFile">Import: choose a file, or paste OPML</label>
|
||||
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
|
||||
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||
<textarea id="opmlText" aria-label="OPML to import" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--field);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
||||
$<HTMLButtonElement>('#oimp').onclick=async()=>{
|
||||
@@ -783,6 +842,7 @@ api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
// ipx's version and the page's, each released on its own.
|
||||
// Also at the foot of Settings: the tooltip alone was out of reach of a keyboard and of a phone (#309).
|
||||
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
|
||||
// Whether the link to Admin shows, now and before the first frame of the next visit.
|
||||
document.documentElement.toggleAttribute('data-admin',!!u.admin);
|
||||
|
||||
@@ -23,6 +23,8 @@ function connect(){
|
||||
const preloadSoon=soon(preloadLists,2000);
|
||||
sse.onmessage=m=>{
|
||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||
// Someone else's download is theirs: its file's name and its failure stay off this page (#294).
|
||||
if((ev.ev==='progress'||ev.ev==='download_error')&&!mine(ev.feed)) return;
|
||||
if(ev.ev==='progress'){
|
||||
const pct=ev.total?ev.done/ev.total*100:0;
|
||||
// Only the row actually downloading. Without the enclosure id this used to paint
|
||||
@@ -48,7 +50,9 @@ function connect(){
|
||||
else if(ev.ev==='download_error'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
|
||||
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
|
||||
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
|
||||
}
|
||||
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
|
||||
else if(ev.ev==='signal_hits'){
|
||||
@@ -62,7 +66,7 @@ function connect(){
|
||||
else if(ev.ev==='feed_done'){
|
||||
setScanning(ev.feed,false);
|
||||
if(!mine(ev.feed)) return;
|
||||
if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||
if(ev.feed===S.feed||S.feed===':all'||S.feeds.some(f=>f.id===ev.feed&&f.group===S.feed)) refreshEntries();
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
/* ---------------- feed page ---------------- */
|
||||
function statHTML(f: Feed){
|
||||
return `<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>`;
|
||||
}
|
||||
/// The open feed's counts, last check and error, from a row that came after its page was drawn:
|
||||
/// the page kept an error a later check had cleared (#316). Not the whole page, which would close
|
||||
/// the open item.
|
||||
function patchFeedHead(f: Feed){
|
||||
const meta=document.querySelector<HTMLElement>('#content .fhead .meta'); if(!meta) return;
|
||||
meta.querySelector('.fail')?.remove();
|
||||
const stat=meta.querySelector('.stat'); if(!stat) return;
|
||||
// An OPML's header has a stat line of its own.
|
||||
if(!S.feeds.some(c=>c.group===f.id)) stat.outerHTML=statHTML(f);
|
||||
meta.querySelector('.stat')!.insertAdjacentHTML('afterend',failBannerHTML(f));
|
||||
wireFailBanner(meta,f);
|
||||
}
|
||||
function renderFeed(){
|
||||
paintScanning(); // another feed's page may be the one open now
|
||||
const box=$('#content');
|
||||
@@ -14,16 +31,13 @@ function renderFeed(){
|
||||
const name=f?(f.title||f.id):sg?sg.name:v.title;
|
||||
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
|
||||
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
box.innerHTML = (f ? `
|
||||
const unreadAll=unreadInAll();
|
||||
box.innerHTML = (f&&kids.length ? groupHead(f,kids) : f ? `
|
||||
<div class="fhead slim">
|
||||
${artHTML(f.image,name)}
|
||||
<div class="meta">
|
||||
<h2>${esc(name)}</h2>
|
||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
||||
${statHTML(f)}
|
||||
${failBannerHTML(f)}
|
||||
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
||||
@@ -34,6 +48,7 @@ function renderFeed(){
|
||||
<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="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>
|
||||
${keepHTML(f)}
|
||||
<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 danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
@@ -60,7 +75,8 @@ function renderFeed(){
|
||||
|
||||
const pane=document.createElement('div');
|
||||
pane.id='split';
|
||||
if(f) pane.className='one';
|
||||
// One feed's list leaves out the feed column; an OPML's shows are told apart by it.
|
||||
if(f&&!kids.length) pane.className='one';
|
||||
pane.innerHTML='<div id="list">'+sortHead()+
|
||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
|
||||
@@ -99,26 +115,25 @@ function sortBy(col: string){
|
||||
S.offset=0; renderFeed(); loadEntries();
|
||||
}
|
||||
|
||||
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||
/// An OPML subscription's page lists the items of the shows inside it together, as All
|
||||
/// Subscriptions does every feed's (#283), under the header a feed has, its buttons in the
|
||||
/// same places: it is still an ordinary feed entry underneath.
|
||||
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||||
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
|
||||
|
||||
function renderGroup(f: Feed,kids: Feed[]){
|
||||
function groupHead(f: Feed,kids: Feed[]){
|
||||
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||
const gone=kids.filter(c=>c.orphaned).length;
|
||||
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
|
||||
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
||||
$('#content').innerHTML = `
|
||||
return `
|
||||
<div class="fhead slim">
|
||||
${folderArt(f,kids)}
|
||||
<div class="meta">
|
||||
<h2>${esc(f.title||f.id)}</h2>
|
||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||
· ${plural(kids.length,'show')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||
${failBannerHTML(f)}
|
||||
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||||
${gone?`<div class="sub" style="color:var(--warn)">${gone} show${gone===1?' is':'s are'} no longer
|
||||
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||
</div>
|
||||
<div class="acts">
|
||||
@@ -129,38 +144,18 @@ function renderGroup(f: Feed,kids: Feed[]){
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
|
||||
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||
</div>
|
||||
<div class="childlist" id="kidlist"></div>`;
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
|
||||
wireFailBanner($('#content'),f);
|
||||
const draw=()=>{
|
||||
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
|
||||
const box=$('#kidlist'); box.innerHTML='';
|
||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||
for(const c of rows){
|
||||
const el=document.createElement('div');
|
||||
el.className='childrow';
|
||||
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||||
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
||||
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||||
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
||||
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||
el.onclick=()=>selectFeed(c.id);
|
||||
box.appendChild(el);
|
||||
}
|
||||
};
|
||||
$<HTMLInputElement>('#kidSearch').oninput=draw;
|
||||
draw();
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/// Keep, for a show from an OPML or a Patreon creator: it stays yours, a show of its own, should
|
||||
/// the list drop it or you leave the list (#280). A plus to keep it, a check once kept.
|
||||
function keepHTML(f: Feed){
|
||||
if(!f.managed||!f.group) return '';
|
||||
const list=S.feeds.find(p=>p.id===f.group);
|
||||
const name=esc(list?(list.title||list.id):f.group);
|
||||
const title=f.kept?`Kept: stays on your list if ${name} drops it. Stop keeping it`:`Keep it on your list if ${name} drops it`;
|
||||
return `<button class="btn ico" data-a="keep" title="${title}" aria-label="${f.kept?'Kept':'Keep'}" aria-pressed="${!!f.kept}">${f.kept?ICON.check:ICON.plus}</button>`;
|
||||
}
|
||||
async function feedAction(a: string,f: Feed){
|
||||
if(a==='scan'){
|
||||
spinStart(f.id);
|
||||
@@ -175,6 +170,12 @@ async function feedAction(a: string,f: Feed){
|
||||
await loadFeeds(true); renderFeed();
|
||||
}catch(e){ toast(why(e),true); }
|
||||
}
|
||||
if(a==='keep'){
|
||||
try{
|
||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({kept:!f.kept})});
|
||||
await loadFeeds(true); renderFeed();
|
||||
}catch(e){ toast(why(e),true); }
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@ let rowsQueued=0;
|
||||
function patchFeed(row: Feed){
|
||||
const i=S.feeds.findIndex(f=>f.id===row.id);
|
||||
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
||||
if(row.id===S.feed) patchFeedHead(row);
|
||||
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
||||
}
|
||||
|
||||
@@ -35,6 +36,12 @@ const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: s
|
||||
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},
|
||||
};
|
||||
/// Whether a feed's items are in All Subscriptions, or in Signals: not when it, or the OPML or
|
||||
/// creator it came from, is hidden from them (#288, #289). The server leaves out the same.
|
||||
const shownIn=(f: Feed, key: 'hide_from_all'|'hide_from_signals')=>!f[key]&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g[key]));
|
||||
const inAll=(f: Feed)=>shownIn(f,'hide_from_all');
|
||||
/// All Subscriptions' unread count.
|
||||
const unreadInAll=()=>S.feeds.reduce((n,f)=>n+(inAll(f)?f.unread||0:0),0);
|
||||
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
|
||||
/// of a folder holding one, carry a spinner.
|
||||
const scanning=new Map<string, number>();
|
||||
@@ -54,7 +61,16 @@ function paintScanning(){
|
||||
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||
}
|
||||
// A feed's button turns on its own page only, wherever the person has gone since pressing it.
|
||||
document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
|
||||
const self=spinFeed!==null&&spinFeed===S.feed, any=document.body.classList.contains('scan-any');
|
||||
document.body.classList.toggle('scan-this',self);
|
||||
// Turning was all that said a check was running, and with motion reduced nothing turns (#310).
|
||||
for(const b of $$('#scanAll,.fhead [data-a=scanall],.fhead [data-a=scan]')){
|
||||
const on=b.dataset.a==='scan'?self:any;
|
||||
b.dataset.label??=b.getAttribute('aria-label')!;
|
||||
if(on) b.setAttribute('aria-busy','true'); else b.removeAttribute('aria-busy');
|
||||
b.title=on?'Checking for new items…':b.dataset.label;
|
||||
b.setAttribute('aria-label',b.title);
|
||||
}
|
||||
}
|
||||
/// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
|
||||
/// because a feed's page is redrawn as its items come in and would take a class on the button
|
||||
@@ -62,15 +78,16 @@ function paintScanning(){
|
||||
/// between one feed and the next, many times a second (#231).
|
||||
let spinFeed: string|null=null;
|
||||
function spinStart(feed: string|null){
|
||||
if(feed===null) document.body.classList.add('scan-any');
|
||||
else { spinFeed=feed; paintScanning(); }
|
||||
if(feed===null) document.body.classList.add('scan-any'); else spinFeed=feed;
|
||||
paintScanning();
|
||||
}
|
||||
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves
|
||||
/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
|
||||
/// (an error ended the job) stops them all.
|
||||
function spinEnd(done?: {only: string|null, forced: boolean}){
|
||||
if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any');
|
||||
if(!done||done.forced&&done.only===spinFeed){ spinFeed=null; paintScanning(); }
|
||||
if(!done||done.forced&&done.only===spinFeed) spinFeed=null;
|
||||
paintScanning();
|
||||
}
|
||||
|
||||
function renderFeeds(){
|
||||
@@ -90,7 +107,7 @@ function renderFeeds(){
|
||||
const drawn=new Map<string,HTMLImageElement[]>();
|
||||
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
|
||||
list.innerHTML='';
|
||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||
const unreadAll=unreadInAll();
|
||||
const places=document.createElement('div');
|
||||
places.className='places';
|
||||
for(const [id,v] of Object.entries(VIEWS)){
|
||||
@@ -99,6 +116,7 @@ function renderFeeds(){
|
||||
el.tabIndex=0; el.dataset.id=id;
|
||||
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||
asLink(el,S.feed===id);
|
||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
||||
places.appendChild(el);
|
||||
@@ -162,6 +180,7 @@ function renderFeeds(){
|
||||
`</small></div>`+
|
||||
(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>`;
|
||||
asLink(el,S.feed===f.id);
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
|
||||
@@ -182,10 +201,10 @@ function selectFeed(id: string){
|
||||
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||
function failBannerHTML(f: Feed){
|
||||
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||
if(f.failing) return `<div class="sub fail" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||
if(f.last_error) return `<div class="sub fail" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||
return '';
|
||||
}
|
||||
function wireFailBanner(box: ParentNode,f: Feed){
|
||||
|
||||
@@ -51,7 +51,7 @@ function refreshFeed(){
|
||||
document.body.append(spin);
|
||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||
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(why(e), true));
|
||||
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; });
|
||||
|
||||
@@ -24,11 +24,9 @@ function keepList(key: string, r: ListPage){
|
||||
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
|
||||
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||
function prefetch(feed: string, filter=S.filter){
|
||||
// Currently Listening and the Directory draw from their own address; a folder's page lists its
|
||||
// feeds, not items.
|
||||
// Currently Listening and the Directory draw from their own address.
|
||||
const url=VIEWS[feed]?.url;
|
||||
if(url) return prefetchPage(url);
|
||||
if(S.feeds.some(c=>c.group===feed)) return;
|
||||
const key=listKey(feed,filter);
|
||||
if(lists.has(key)||fetching.has(key)) return;
|
||||
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||
@@ -51,8 +49,9 @@ const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=
|
||||
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||
/// are fetched again when next wanted.
|
||||
function forgetLists(feed: string){
|
||||
// A Signal holds items of any feed.
|
||||
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) lists.delete(k);
|
||||
// A Signal holds items of any feed, and an OPML's list its shows' (#283).
|
||||
const group=S.feeds.find(f=>f.id===feed)?.group;
|
||||
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')||(group&&k.startsWith(group+'\n'))) lists.delete(k);
|
||||
}
|
||||
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
|
||||
/// and the feeds with something unread, most first.
|
||||
@@ -125,7 +124,9 @@ function listLoading(box=$('#eps')){
|
||||
function renderEntries(){
|
||||
const box=$('#eps'); if(!box) return;
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
|
||||
const unread=f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
// An OPML's own row counts nothing: its shows do.
|
||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||
const unread=kids.length?kids.reduce((n,x)=>n+x.unread,0):f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
$('#count').textContent=
|
||||
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||
@@ -161,13 +162,15 @@ function epEl(e: Entry){
|
||||
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 name=entryName(e);
|
||||
// The title is a button, its click the row's, so Tab and a screen reader can open the item:
|
||||
// the row alone was a click target only a mouse could reach (#298).
|
||||
el.innerHTML=`
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
|
||||
player.guid===e.guid?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</button>
|
||||
<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>
|
||||
<div class="body">
|
||||
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
||||
<button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
|
||||
<div class="line">${[
|
||||
num&&`<span>${num}</span>`,
|
||||
left&&`<span>${left}</span>`,
|
||||
@@ -282,7 +285,7 @@ function setRead(e: Entry,read: boolean){
|
||||
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||
function markRead(e: Entry){
|
||||
if(e.read) return;
|
||||
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(why(err),true); });
|
||||
}
|
||||
|
||||
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
|
||||
@@ -375,6 +378,12 @@ function showDetail(e: Entry|null){
|
||||
box.innerHTML=detailHtml(e);
|
||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
||||
const view=box.querySelector<HTMLSelectElement>('.tview');
|
||||
if(view) view.onchange=()=>{
|
||||
textView=view.value;
|
||||
try{ localStorage.setItem('ipx.textview',textView); }catch{}
|
||||
box.querySelector('.dbody')!.innerHTML=textOf(e);
|
||||
};
|
||||
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):undefined));
|
||||
syncPlayButtons();
|
||||
@@ -400,16 +409,28 @@ function detailHtml(e: Entry){
|
||||
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
|
||||
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
|
||||
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
|
||||
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
|
||||
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||
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'}"
|
||||
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"
|
||||
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
|
||||
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>`:''}</span>
|
||||
</div>
|
||||
${narrow?encs:''}
|
||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||
<div class="dbody">${textOf(e)}</div>`;
|
||||
}
|
||||
|
||||
/// How an item whose feed sends Markdown beside its HTML is shown, kept from one item to the
|
||||
/// next (#285): the publisher's HTML, or the Markdown made HTML.
|
||||
const TEXT_VIEWS=[['html','HTML'],['markdown','Markdown']];
|
||||
let textView=(()=>{ try{ return localStorage.getItem('ipx.textview'); }catch{ return null; } })()||'html';
|
||||
function textOf(e: Entry){
|
||||
// Cleaned server-side with ammonia, as the description is.
|
||||
if(e.markdown&&textView==='markdown'&&e.markdown_html) return e.markdown_html;
|
||||
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
|
||||
}
|
||||
|
||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||
@@ -455,7 +476,7 @@ function encBox(x: Enclosure){
|
||||
// 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
|
||||
// through here, which would make ipx a fetch-anything relay.
|
||||
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
||||
const viewable = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
||||
return `<div class="encbox">
|
||||
${kindIcon(x)}
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
|
||||
@@ -165,7 +165,8 @@ function installNativePlayback(){
|
||||
case 'error':
|
||||
N.paused = true;
|
||||
fire('pause');
|
||||
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
|
||||
report('player_error', {msg: `native: ${m.message || 'error'}`, ...playing()});
|
||||
toast('Unable to play this file. Try again, or download it again.', true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
|
||||
return ready.then(()=>{
|
||||
// Its playlist and audio requests join the trace of the tap that played them (#192).
|
||||
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
|
||||
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
||||
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Unable to play the DeadAir version. Try again, or turn DeadAir off for this show.',true);
|
||||
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
||||
hls.loadSource(url); hls.attachMedia(audio);
|
||||
});
|
||||
@@ -48,10 +48,18 @@ function markPlayed(){
|
||||
setRead(e,true).catch(()=>{});
|
||||
}
|
||||
|
||||
/// What a failed play() means to whoever pressed play; the browser's own words go to the log (#304).
|
||||
function playFailed(err: Error){
|
||||
// A newer load took its place: nothing failed.
|
||||
if(err.name==='AbortError') return;
|
||||
report('player_error',{msg:`play(): ${err.name}: ${err.message}`,...playing()});
|
||||
toast(err.name==='NotAllowedError' ? 'The browser held playback back. Press play to start it.'
|
||||
: 'Unable to play this file. Try again, or download it again.', true);
|
||||
}
|
||||
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
if(!enc||!isPlayable(enc)){
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet. Download it first, with its button on the item.', true);
|
||||
return;
|
||||
}
|
||||
// The same file carries on where it was; another of the item's files starts from its top. So
|
||||
@@ -76,8 +84,8 @@ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
player.marked=false;
|
||||
paintDead();
|
||||
}
|
||||
const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
|
||||
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
|
||||
const go=()=>audio.play().catch(playFailed);
|
||||
if(wait) wait.then(go,playFailed); else go();
|
||||
renderEntries();
|
||||
}
|
||||
function mediaSession(e: Entry,f: Feed|undefined){
|
||||
@@ -95,6 +103,8 @@ audio.addEventListener('timeupdate',()=>{
|
||||
$('#pcur').textContent=clock(audio.currentTime);
|
||||
$('#pdur').textContent=clock(d);
|
||||
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||
// Its value is thousandths, which a screen reader would read out as they are (#301).
|
||||
$('#seek').setAttribute('aria-valuetext',`${clock(audio.currentTime)} of ${clock(d)}`);
|
||||
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||
// that would write back whatever the list said, however old.
|
||||
if(!audio.paused) player.moved=true;
|
||||
@@ -251,7 +261,7 @@ const KEYS: Record<string,()=>void>={
|
||||
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||
// they are disabled.
|
||||
o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
|
||||
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
||||
v:()=>{ const u=webUrl(cur()?.link); if(u) window.open(u,'_blank','noopener'); },
|
||||
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||||
'[':()=>matchMedia('(max-width:820px)').matches
|
||||
|
||||
@@ -18,6 +18,10 @@ function signalOf(view: string|null){
|
||||
return id===null ? undefined : S.signals.find(s=>s.id===id);
|
||||
}
|
||||
|
||||
/// Whether you have Signals (#271): everyone does on the server's key; on your own, once you have
|
||||
/// turned them on in Settings with a key TypeSafe took.
|
||||
const signalsOn=()=>S.me?.signals==='server'||!!S.me?.typesafe_key_set;
|
||||
|
||||
/// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
|
||||
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
|
||||
const SIGNALS=':signals';
|
||||
@@ -26,6 +30,7 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
||||
const el=document.createElement('div');
|
||||
el.className='feed '+cls+(S.feed===id?' sel':'');
|
||||
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
||||
asLink(el,S.feed===id);
|
||||
el.onclick=()=>{ open(); nav(false); };
|
||||
return el;
|
||||
}
|
||||
@@ -42,6 +47,7 @@ function signalTile(sg: {name: string, icon: string|null}){
|
||||
}
|
||||
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||
function signalFolder(list: HTMLElement, q: string){
|
||||
if(!signalsOn()) return;
|
||||
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
||||
if(q&&!mine.length) return;
|
||||
const open=expanded.has(SIGNALS)||!!q;
|
||||
@@ -101,7 +107,7 @@ function renderSignals(){
|
||||
}
|
||||
|
||||
function signalHead(sg: Signal){
|
||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||
const nokey=S.me&&!signalsOn();
|
||||
return `
|
||||
<div class="fhead slim">
|
||||
${signalTile(sg)}
|
||||
@@ -130,14 +136,14 @@ async function signalAction(a: string, sg: Signal){
|
||||
|
||||
/// Making a Signal, or changing one.
|
||||
function signalModal(sg?: Signal){
|
||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||
const nokey=S.me&&!signalsOn();
|
||||
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
||||
<div class="field"><label for="sgname">Name</label>
|
||||
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
||||
aria-expanded="false"></button>
|
||||
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
||||
<div id="sgpicker" hidden>
|
||||
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
||||
<div class="inline">${searchBox('<input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">')}
|
||||
<button type="button" class="dlink" id="sgjev" title="Pick an icon for me">Auto</button>
|
||||
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
||||
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
||||
@@ -146,10 +152,9 @@ function signalModal(sg?: Signal){
|
||||
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
||||
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
||||
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
||||
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
|
||||
<span class="hint">Every item in the feeds you subscribe to is read, the last ${plural(S.me?.signal_days??7,'day')} of them
|
||||
and each new one, and the ones about this subject are pulled out: its name, these words and
|
||||
your sentence together. The rest stays noise, in its own feed. Each item is one call on your
|
||||
TypeSafe key.${sg?' A change here reads everything again.':''}</span></div>
|
||||
your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads again what was picked out, and listens for it in new items; what was passed over stays passed over.':''}</span></div>
|
||||
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
||||
@@ -280,6 +285,7 @@ const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Ge
|
||||
async function keySetting(){
|
||||
closeModal();
|
||||
await prefsModal();
|
||||
$<HTMLInputElement>('#ssigon').checked=true; $('#ssigkey').hidden=false;
|
||||
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
||||
}
|
||||
document.addEventListener('click',(e: MouseEvent)=>{
|
||||
|
||||
@@ -35,6 +35,12 @@ interface Feed {
|
||||
unread: number;
|
||||
subscribers: number;
|
||||
pinned: boolean;
|
||||
/** From an OPML, and yours to keep should it drop the feed (#280). */
|
||||
kept: boolean;
|
||||
/** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */
|
||||
hide_from_all: boolean;
|
||||
/** The same for Signals (#289). */
|
||||
hide_from_signals: boolean;
|
||||
private: boolean;
|
||||
private_by_address: boolean;
|
||||
}
|
||||
@@ -47,6 +53,9 @@ interface Entry {
|
||||
link: string | null;
|
||||
published: number | null;
|
||||
description: string | null;
|
||||
/** The item's Markdown as written, when its feed sends it beside the HTML, and made HTML (#285). */
|
||||
markdown: string | null;
|
||||
markdown_html: string | null;
|
||||
read: boolean;
|
||||
flagged: boolean;
|
||||
image: string | null;
|
||||
@@ -84,6 +93,10 @@ interface Me {
|
||||
version: string;
|
||||
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
||||
typesafe_key_set: boolean;
|
||||
/// Whose TypeSafe key Signals are judged on (#271): the server's, for everyone, or each person's own.
|
||||
signals: 'server' | 'own';
|
||||
/// How many days back a new Signal reads (#276), an admin's setting.
|
||||
signal_days: number;
|
||||
}
|
||||
|
||||
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).
|
||||
|
||||
@@ -5,8 +5,21 @@ const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = docum
|
||||
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
|
||||
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
|
||||
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
|
||||
const ESCAPED: Record<string,string> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
/// A row that opens a feed, a place or a show (the feed list, the Directory): a link to a screen
|
||||
/// reader, named by what it shows, the open one marked current. Not a button, whose contents a
|
||||
/// screen reader flattens, which would hide a folder's triangle inside it; as a bare div it read as
|
||||
/// text, nothing to press (#298).
|
||||
function asLink(el: HTMLElement, current: boolean){
|
||||
const said=(s: string)=>el.querySelector(s)?.textContent?.trim();
|
||||
const marks=$$('[role=img][aria-label],.subbed[aria-label]',el).map(m=>m.getAttribute('aria-label'));
|
||||
el.setAttribute('role','link');
|
||||
el.setAttribute('aria-label',[said('b'),said('small'),...marks,el.querySelector<HTMLElement>('.badge:not(.zero)')?.title].filter(Boolean).join(', '));
|
||||
if(current) el.setAttribute('aria-current','page');
|
||||
}
|
||||
const ESCAPED: Record<string,string> ={'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
|
||||
// A feed's link, only if it is a web address: esc() keeps a javascript: or data: one working (#326).
|
||||
const webUrl = (u: string|null|undefined): string|null => /^https?:\/\//i.test(u??'') ? u! : null;
|
||||
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
||||
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
|
||||
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
|
||||
@@ -61,6 +74,20 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||||
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
|
||||
|
||||
/// A search box with an x at its right end that clears it, shown once something is typed (#291).
|
||||
const searchBox=(input: string, cls='')=>
|
||||
`<span class="clearable ${cls}">${input}<button type="button" class="clearx" title="Clear" aria-label="Clear">${ICON.close}</button></span>`;
|
||||
// Every box's x, wherever it is drawn: cleared as if the typing were deleted, so the box's own input
|
||||
// handler runs and its list comes back whole.
|
||||
document.addEventListener('click',(e: MouseEvent)=>{
|
||||
const x=(e.target as Element).closest?.('.clearx');
|
||||
const box=x?.parentElement?.querySelector('input');
|
||||
if(!box) return;
|
||||
box.value='';
|
||||
box.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
box.focus();
|
||||
});
|
||||
|
||||
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
|
||||
/// becomes how much shorter it is once the copy is ready (the dead_air event).
|
||||
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
|
||||
@@ -102,11 +129,18 @@ window.addEventListener('error',e=>report('page_error',{msg:String(e.message||e.
|
||||
window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e.reason?.message||e.reason)}));
|
||||
|
||||
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
||||
// The browser's "Failed to fetch" said nothing anyone could act on (#304).
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts})
|
||||
.catch(()=>{ throw new Error('Unable to reach iPX. Check your connection and try again.'); });
|
||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||
// whole 502 page (#219).
|
||||
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
||||
if(!r.ok){
|
||||
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||
// whole 502 page (#219). A 500's words are the daemon's own workings, which its log already
|
||||
// has; they are not something to show the person who pressed the button (#304).
|
||||
const said=!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>'');
|
||||
throw new Error(r.status===500 ? 'iPX could not do that just now. Try again in a minute.'
|
||||
: said || 'Unable to reach iPX just now. Try again in a minute.');
|
||||
}
|
||||
return r.status===204?null:r.json().catch((): null=>null);
|
||||
}
|
||||
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||
@@ -161,10 +195,19 @@ async function share(title: string, url: string, btn?: HTMLElement){
|
||||
}
|
||||
|
||||
function toast(msg: string, bad?: boolean){
|
||||
const t=document.createElement('div');
|
||||
const t=document.createElement('div'), box=$('#toasts');
|
||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||
$('#toasts').appendChild(t);
|
||||
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||
const go=()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)};
|
||||
// An error stays until it is put away: gone after six seconds, it was easy to miss and could not
|
||||
// be read again (#303).
|
||||
if(bad){
|
||||
const x=document.createElement('button');
|
||||
x.type='button'; x.className='toastx'; x.title='Dismiss'; x.setAttribute('aria-label','Dismiss'); x.innerHTML=ICON.close;
|
||||
x.onclick=go; t.appendChild(x);
|
||||
}else setTimeout(go,3200);
|
||||
box.appendChild(t);
|
||||
// A run of failed downloads would otherwise stack errors up the screen.
|
||||
while(box.children.length>4) box.firstElementChild!.remove();
|
||||
}
|
||||
const clock = (s: number) => {
|
||||
s=Math.max(0,Math.floor(s||0));
|
||||
|
||||
Reference in New Issue
Block a user