38 Commits

Author SHA1 Message Date
0be8eb5cdf ipx 1.5.0, ipx-directory 1.4.0 and ipx-web 1.3.0
The security audit's 26 findings, three dependency advisories and the
performance issues found in traces (#318-#352), each already deployed
as a -dev build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:41:57 +00:00
e1291c1627 Count unread feed by feed (#352)
After #347 an unread count read only the items since each feed's
unread_from, but Postgres guessed a range join across every feed
keeps a third of its rows (17,000 where there are 200), so it hashed
all 22,949 of the person's entry_state rows. Counted in a subquery per
subscription, the feed and its unread_from are constants there, and
it looks up the few items since and their read state by key: on
production the feed list's unread counts went from 19.7 to 14.8 ms and
All Subscriptions' unread count from 19 to 13.

The feed list's unread counts and count_of for Unread without a search
(All Subscriptions, a feed, an OPML's shows) use it; a page of unread
items still joins, since it needs rows in order, not a count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:14:36 +00:00
a11e3b6367 Unread lists start from each person's oldest unread item (#347)
All Subscriptions' Unread page, its count and the feed list's unread
counts read every item of the person's feeds and hashed all their
entry_state rows to find the few unread: 11,727 items and 22,949 rows
for 44, 27-32 ms on production, growing with the back catalogue. A
NOT EXISTS rewrite of the same scan was no faster.

subscriptions.unread_from says every item of the feed first seen
before it is read by this person. Unread queries join the subscription
on e.first_seen >= coalesce(unread_from, 0), and the new index
entries_seen (feed_id, first_seen) makes that a range per feed.

Nothing has to keep a list in step, so nothing can drift: the reap job
and daemon start move it up to the oldest unread item (hidden ones
too, since a block list can be lifted), never past an hour ago, so an
item a scan is storing meanwhile is not passed over. The one way an
older item becomes unread, marking it so, clears the person's value
for that feed. NULL, or stale, only means reading more.

The filter test now subscribes its reader, as every list's reader is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:04:32 +00:00
cb4ee5a570 Kept artwork off the async threads, and one fetch per missing image (#345 #349)
#345: a cache hit made four blocking std::fs calls on a tokio worker,
and the cache was on shfs (FUSE). A page asks for about fifty images at
once, so hits ran 11 ms at p50 and over a second at p90. Reading,
keeping and the reap job's trim now run in spawn_blocking. Production's
cache also moves to /mnt/fast in the compose file, outside the repo.

#349: two requests for the same uncached image both fetched it from its
publisher. A per-address lock now makes the second wait for the first,
then find the image kept. The lock is forgotten once its last holder is
done, whichever way the request ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:33:41 +00:00
c4878731c6 Count a Signal's items and unread in one pass (#348)
signal_rows ran the same correlated subquery over signal_verdicts,
entries, subscriptions and entry_state twice a Signal, once for items
and once for unread. One grouped subquery now gives both: count(*) and
a sum of the unread, joined to the person's Signals. Same results on
SQLite and Postgres (the existing counts test runs on both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:30:30 +00:00
21fa0ae476 Run the item page's count and the feed list's queries together (#346)
entry_page awaited the page's query and then its count, about 40 and
24 ms on /api/entries, the busiest route; feed_list awaited five
independent queries in turn (entries, downloaded, unread, blocked,
feeds), 59 ms of a 67 ms /api/feeds, with the pool's connections idle.
Both now use tokio::try_join!. On SQLite they queue on its connection
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:09 +00:00
aa430f6d50 A page's files are looked up by feed and item, not item alone (#344)
entries_where found a page's enclosures with guid IN (...). The only
index is enclosures_entry (feed_id, guid), so Postgres read the whole
of it: 587k rows, 9,150 buffers and 250 ms for user 2's 50 newest, on
every /api/entries, feed page, Signal page and single item. With the
page's feed ids as well it is an index lookup, 0.5 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:24:29 +00:00
df643536c0 ipx-web drops the header TRUSTED_HEADER names from anyone but the tunnel (#323)
ipx trusts [web] trusted_header from Caddy's address, and Caddy
stripped only the two Cf-Access-* names from requests not from
TUNNEL_PEER. With trusted_header set to anything else (X-Remote-User
for another proxy), a request through ipx-web carried it to ipx as
Caddy's and was believed: anyone could sign in as anyone.

TRUSTED_HEADER, defaulting to Cloudflare's name, is stripped the same
way. Production uses Cloudflare's header and sets nothing, so it
behaves as before. caddy adapt shows the delete list with and without.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:20:55 +00:00
543d35f02c A snapshot's subscriber counts are bounded above (#342)
Store::snapshot clamped a count only from below, and LISTING sums each
feed's counts across instances, cast to BIGINT. Two instances sending
i64::MAX for a feed overflowed it (Postgres: bigint out of range;
SQLite: integer overflow), and list() and popular() failed for every
feed: the public pages 503, /v1/feeds and /v1/popular 500, until an
admin dropped the instance. Counts are now clamped to a million.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:17:43 +00:00
0de0fb4332 Bound icon picks per person, and keep people's Signals apart (#340 #343)
#340: POST /api/signals/icon spends the server's TYPESAFE_KEY for
anyone signed in, whatever signals_on_server says, and is not tied to
a Signal, so a loop on it ran up the bill and, through the shared pace
and a 429's back-off, held back everyone's judging. Each person now has
20 picks an hour; past that it is a 429 the page shows as a toast.

#343: on the server's key, calls() put everyone's Signals on an item in
one call (#275), so one person's free-text description was part of the
question that decided another's verdicts. Each person's Signals on an
item are now a call of their own. The saving #275 made is lost only
where two people have Signals on the same item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:02 +00:00
79b496bdfb A password check holds its turn until it finishes (#324)
check_password took a permit from CHECKS in the async function and
ran Argon2 in spawn_blocking. A client that hung up dropped the
future and with it the permit, while the blocking check ran on, so a
flood of sign-ins that disconnected at once ran unbounded Argon2 work,
the very thing CHECKS was added for (#137).

The permit is now owned (CHECKS is an Arc) and moves into the blocking
closure, so it is freed when the check ends, whoever stopped waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:03:19 +00:00
a20fade8ad OPML uploads are capped at 2,000 feeds and checked by address once (#338)
subscribe_opml, reachable by anyone signed in through POST /api/opml,
held the catalogue lock while, for each outline, it searched every
subscription and the whole catalogue for its address and probed
unique_slug's suffixes in order. 2 MB holds about 40,000 outlines, so
one upload was quadratic work with every other catalogue change
waiting, and its feeds stayed in the catalogue for good.

opml_feeds lists a document's feeds and refuses more than MAX_LISTED,
the limit a subscribed list already has (#337): a 400 from the web, a
message from `ipx opml`. subscribe_opml takes that list and looks
addresses up in a map built once, the first feed with an address
winning as the search in order did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:00:29 +00:00
daf95a25cd Only a subscriber or an admin marks a feed private (#333)
patch_feed let private = true through for anyone signed in, as the
field's doc comment meant for subscribers, but never checked that the
caller subscribed. Ids are slugs of titles, so anyone could mark
another person's feed, or a whole OPML (whose shows go with it),
private, keeping it out of the directory, and promote a derived feed
they did not read out of its OPML; only an admin could undo either.

A non-admin now needs a subscription to the feed, or gets 404, as the
per-person settings above already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:56:36 +00:00
91de1114d8 Live updates go to those who read the feed, while their account exists (#325 #335)
/api/events sent every daemon event to every open page. Event::redacted
strips the query and the password from addresses, not the path, so a
paid feed's key in its path (feeds.supercast.com/feeds/<key>) reached
everyone signed in through its FeedError or FeedMoved (#325). And the
stream checked who was asking once, when it opened: a removed account's
page kept getting events until it closed (#335).

Each stream now looks its account and subscriptions up again at most
every 30 s, as an event arrives, ends when the account is gone, and
sends an event about a feed only to someone subscribed to it. Admins
get everything, for the log; Reaped, a file path, goes to admins only;
SignalHits to the person it is for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:51:12 +00:00
37c08195f1 A new password signs the account out everywhere (#334)
ipx user passwd, the one way to change a password and the step taken
when one may be known to someone else, only replaced the hash. Every
session stayed, and session_user moves `seen` on each use, so a
session someone kept using never reached the idle cutoff.

set_password now deletes the account's sessions too. API tokens stay:
a reset that broke every script would be a reason not to reset, and
the owner lists and removes tokens in Settings. The command says how
many still work.

Also corrects the first-start log line from #321: there is no way to
change a password in the page, so it names the command alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:45:41 +00:00
d1149e1236 Requests that change something are refused from another site (#336)
Six POST routes take no body, so a cross-origin form or no-cors fetch
reached them with no preflight: Sign out, Read all (one feed's and
every feed's), Download, Refresh a listing and subscribing to a
Directory feed. The ipx cookies are SameSite=Lax, which stops a form
on an unrelated site but not one on a sibling subdomain, and the
Cloudflare Access path does not use ipx's cookies at all.

cross_site_refused wraps every route, login included: anything but
GET, HEAD and OPTIONS is a 403 when Sec-Fetch-Site is not same-origin
or none, or, from a browser too old to send it, when Origin is not the
Host. A request with neither header is not a browser's (a script with
a token, the healthcheck, k6) and goes through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:33:39 +00:00
46fe0dddbf directory.toml's fetch_internal lets the refresher reach listed addresses (#329)
3bfd8f8 gave the refresher the guard with no exceptions, which broke
the browser suite: its directory refreshes fixture feeds on 127.0.0.1,
and five Directory tests waited on listings that never filled. The
cargo tests passed because none of them fetches over HTTP.

directory.toml takes fetch_internal as config.toml does, empty by
default; the suite's lists 127.0.0.1. Production's has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:33:30 +00:00
d35b8054b4 Bound the scan work one feed's author can ask for (#339 #337)
Both run on the daemon's single worker, so every other feed's check
and download waited behind them.

#339: prefer_https asked each new artwork host whether it served the
image on https, up to 20 s each, one after another, and the per-scan
cache is keyed by the authority, which ports and user names multiply
for one server. It now asks at most eight hosts a read; past that an
http image stays http, for /api/art to fetch, as one that fails does.

#337: a list (OPML or Patreon) may bring in 2,000 feeds; past that
sync_group refuses it whole, so the feed shows an error and nothing
changes. davewiner's, the largest seen, had 922. sync_group also read
the whole feeds table and rebuilt an id map with a Feed cloned per
entry for every new feed; it now reads the table once and checks ids
against sets, and unique_slug takes a predicate rather than a map.
same_feed skips parsing two URLs unless both are Patreon's, since only
those can differ and match.

sync_group and sync_opml lose the parent argument, which nothing used
once promote_kept stopped taking it (#332).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:18:32 +00:00
3bfd8f848b The directory's refresher refuses internal addresses (#329)
The refresher fetches every feed a snapshot names, and anyone with a
write token sends snapshots; a listed feed's redirect goes wherever its
publisher likes. Nothing checked the destination, so the directory
fetched loopback, its Docker network and the LAN, and what answered
became a listing's title and description on its public pages.

Its client now has ipx-feed's guard (#320) with nothing allowed: no
feed the directory lists is on this network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:10:32 +00:00
fc04a741a6 The directory's public pages read the list once a minute (#341)
/, /all, /search and /category are anyone's to ask for (#215), and each
called Store::list: the whole catalogue, joined with the snapshots and
grouped, on the 10-connection pool the refresher and every server's
/v1 calls share, on the Postgres that holds production ipx's database.
The pages' Cache-Control did nothing for a request with a query string
of its own, such as /search?q=<random>.

Directory::pages_list keeps the list for a minute behind a mutex held
through the read, so a burst of misses waits on one query rather than
each sending its own. Filtering and rendering still happen per request,
in memory. /v1/feeds, behind a token, still reads the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:06:19 +00:00
80f12cda70 A feed's credentials go to its own host alone (#327 #328)
feed::fetch already sent basic auth only to the feed's own host, but
two paths around it did not:

- download::download attached the feed's username and password to
  every enclosure, on whatever host the feed named: a CDN, or the
  measurement and ad-insertion prefixes podcasts route through (#328).
- subscriptions() and promote_kept() gave every feed an OPML or Patreon
  list derives the list's credentials, and fetch's same-host check then
  compared against the derived feed's own URL, so the list's password
  went to every host the OPML named, on every scan (#327).

config::Feed::auth_for and credentials_for compare hosts once, and
each of the three uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:02:10 +00:00
4f7dab9c04 A kept show keeps its list's private mark and credentials (#332)
promote_kept copied the list's settings from the current config, but
leave, rm and the directory cleanup remove the list from config before
calling retire_group, so the lookup found nothing: the promoted show
got private = false and no username or password, and the next snapshot
sent its URL to the directory. Only sync_group, which promotes while
the list is still there, kept them.

The callers now pass the list's settings as they were before removal.
Where they are no longer known (retire_stranded, for a list removed
long ago), the promoted show is private: a URL kept back by mistake
can be shared by hand, one shared by mistake cannot be taken back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:59:02 +00:00
cc3b8742bc The first admin gets a random password, not admin / ipodderx (#321)
A database with no accounts created admin with the hard-coded password
ipodderx, published in the README, the docs and the sample compose
file, and the log line announcing it printed the password. /api/login
is open to anyone who reaches the port, and nothing made anyone change
it, so a fresh install was an admin account anyone could sign into.

Now the password is IPX_ADMIN_PASSWORD when set, for scripted installs,
or 24 random hex characters written to admin-password beside
config.toml, created 0600 so it is never readable by others even for a
moment. The log says where it is, never what it is, as it does for the
web token (#40). Existing databases are untouched: seeding runs only
with no accounts at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:34 +00:00
c99f481124 Update rustls-webpki, webpki-roots and ctutils for three advisories (#351)
cargo deny check failed on main:
- RUSTSEC-2026-0382, rustls-webpki 0.103.15: verification may accept
  distrusted intermediates. Now 0.103.16.
- RUSTSEC-2026-0381, webpki-roots 1.0.9: bundled roots trust distrusted
  emSign intermediates. Now 1.0.11. It surfaced once the first was fixed.
- RUSTSEC-2026-0385, ctutils 0.4.2: non-constant-time masks and u128
  comparisons. Now 0.4.5.

All transitive; Cargo.lock only. cargo deny check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:09 +00:00
a20062d0f7 Fetches refuse internal addresses unless fetch_internal lists them (#320)
ipx's clients had no destination policy and followed redirects
anywhere. Adding a feed returned the status or first line of whatever
answered at the address; an enclosure declared as audio was downloaded
whole from any URL and served back through /media; /api/art fetched
any image a feed named. Feeds come from publishers and anyone signed
in, so loopback, the Docker network and the LAN were all reachable.

ipx-feed's guard is a connector layer: after the name resolves and the
connection opens, before the request is written, it looks at the
peer's address and refuses anything off the public internet unless
config.toml's [general] fetch_internal lists it. Being on the
connection, it covers every request on ctx.client and feed_client,
each redirect hop, IP literals (which never reach a DNS resolver) and
a name that resolves differently the second time. reqwest's rustls
connection reports the peer address; a test run by hand checks HTTPS
gets through, since were it lost every https fetch would be refused.

The directory is on the server's own network by design
(http://ipx-directory:8100), so it moves to local_client, unguarded,
set by an admin. The browser suite and load tests serve their feeds on
loopback and list 127.0.0.1.

Not covered: librqbit's own connections (trackers, peers), which do
not go through these clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:27:40 +00:00
6d71613e19 ipx fetches a .torrent itself, capped at 10 MB (#330)
Handed an http(s) URL, librqbit 9.0.1 fetched the .torrent with its own
reqwest client (no timeout) and response.bytes(), buffering the whole
body: a publisher could serve one without end, for up to the 30-minute
metadata budget, two torrents at a time, on a host with no swap.

Now ipx fetches it on ctx.client (connect and read timeouts), stops past
10 MB, holds it to the stall budget too so a trickle cannot keep a slot,
and gives librqbit the bytes. Magnet links go to librqbit as before.

Not fixed here: librqbit reads each HTTP tracker announce the same way,
and offers no setting to bound it short of disable_trackers, which would
break torrents that need their tracker. #330 stays open for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:15:17 +00:00
f398211fd6 OPML nested past 100 levels is refused before it is parsed (#322)
The opml crate's Outline reader (hard-xml's derive) recurses once per
<outline> level with no limit. A 2 MB upload holds tens of thousands of
levels, enough to overflow a tokio worker's 2 MiB stack, and a stack
overflow aborts the process, not the task. A fetched OPML (up to 50 MB)
reached the same parse on every scan, so a publisher could keep the
daemon in a crash loop.

feed::read_opml counts nesting with quick-xml's streaming reader, which
does not recurse, and refuses past 100 before handing the text to the
opml crate. Every place that parsed OPML goes through it: the upload,
`ipx opml`, a fetched OPML's feeds and its title. The limit also bounds
collect_outlines' recursion and the drop of the nested Vec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:11:21 +00:00
708b69c083 Feed links are followed only when they are web addresses (#326)
An item's link and an enclosure's URL are the publisher's, stored as
written, and the page put them in href and window.open through esc(),
which escapes characters, not schemes: a javascript: or data: link
survived as Open the original, View in a new tab (for a file not on
disk), the Directory's item links and the v key. Show notes already
lose those schemes to ammonia; these four sinks were the exception.

webUrl() passes http(s) only, and each sink goes through it. A link
that fails it shows no link, as an item with none does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:07:47 +00:00
62a2456dde Downloads open in the browser only when they cannot run script (#319)
/media served a downloaded file with ServeFile, which types it by its
extension, and the file and its name are the publisher's. An enclosure
saved as .svg or .html, which download::sniff lets through unless it
starts <html or <!doctype html, opened from "View in a new tab" ran its
script on iPX's origin with the viewer's session.

Now every /media response is nosniff, anything but audio, video, a
non-SVG image, a PDF or plain text is sent as an attachment, and all
but PDFs get the sandbox CSP artwork already had. PDFs go without it
because Chrome refuses to render one in a sandboxed document; its
viewer does not run the PDF's script as the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:01:00 +00:00
ec3dedf113 Torrents download into a folder of their own (#318 #331)
The recorded path was output_folder.join(handle.name()). The name is the
publisher's: librqbit checks it for traversal only when it chooses the
subfolder itself, and ipx passes output_folder, so a multi-file torrent
named "/config" or ".." pointed Delete, the reaper and /media at that
path. With output_folder set, librqbit also puts a multi-file torrent's
files straight into it, not under the name, so the joined path was not
where the files were even for an honest torrent.

Each enclosure now gets torrent-<id> inside its feed's folder, and that
folder is the path: playable() already picks the largest file under a
folder and remove() deletes one (#158). The same folder keeps
overwrite: true from replacing another enclosure's files of the same
name. A torrent half-downloaded before this restarts in its new folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:55:35 +00:00
511246acb2 More step spans where flame graphs showed time with nothing under it (#350)
A week of traces folded with query.py flame left whole requests opaque:
entries_in at 46-82 ms a request with the page's query and its enclosures
together, /api/art 37 ms, /api/status 95 ms, PATCH /api/feeds/{id} 72 ms and
DELETE 45 ms. Each now has a step around what it waits on: the page and its
enclosures; the artwork cache, the check that a feed names it, the fetch and the
write; the status counts; and in changing or leaving a feed, each database call,
the wait for the catalogue's lock and storing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:45:58 +00:00
ccc4766b03 query.py: flame graphs from traces
`query.py flame '<TraceQL>' [since] [out.svg]` folds the matching spans of up to
100 traces into stacks of self time and draws them as a flame graph, with the
folded stacks beside it for speedscope or inferno. A wall-clock picture of where
a route's time goes, waiting included, which a CPU profiler would not show: ipx
waits on Postgres and the disk far more than it computes. The traces are fetched
in one container, a docker run apiece being a second each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:41:12 +00:00
78b81e43aa CLAUDE.md: the style guide path is /src/styleguide, not sytleguide
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:14:06 +00:00
4af12363bc Read a WordPress.com site's posts from its API when the feed is behind a browser check (#317)
pelgranepress.com is on WordPress.com, whose CDN answers /feed/, /feed/atom/ and ?feed=rss2
with HTTP 403 and "Checking your browser...", a JavaScript challenge, whatever the User-Agent.
The checks that hit it failed for hours (8 in the day before this), and the publisher has
not changed anything.

The API at public-api.wordpress.com/wp/v2/sites/<host>/posts has the same posts with no check,
and each post's guid is the feed's own (https://host/?p=<id>), so the items read from it are
the ones already stored. fetch recognises the challenge by its /__challenge/ script, asks the
API instead, and parse reads WordPress's posts JSON as a feed. It sends no title or image for
the feed, which record_feed keeps as they were.

shortcut: the API's default ten posts, as many as WordPress puts in a feed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:06:22 +00:00
84b4203daf A feed's page follows its row after a check (#316)
The header was drawn once, on opening the feed. feed_row put the new row in S.feeds and redrew the
feed list but not the open page, so a check that cleared an error left the error showing, with the
old "checked" time, until the feed was opened again. Seen on Eli Bendersky's website: a connect
timeout at 15:13, a clean forced check at 15:28, and the timeout still on the page.

Only the stat line and the error banner are redrawn: the whole page would close the open item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:37:19 +00:00
0696583547 Reduced motion, touch hover, overscroll and artwork edges (#310 #311 #312 #313 #314 #315)
Fixes from the UI-polish review of the main window (2026-10-19).

Reduced motion took every animation away, and a turning icon was the only sign a check was
running. The refresh buttons now carry aria-busy and a "Checking for new items" label while
checking, and under reduced motion spinners pulse and toasts and sheets fade.

Every :hover rule is behind @media (hover:hover), since Safari keeps :hover on the last thing
tapped; the two phone resets that undid it for .btn and .ep had nothing left to do and are gone.
An item row's actions still show on hover without the query, so a tablet with no pointer can
reach them by tapping the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:16:41 +00:00
6ec2c35291 Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:34 +00:00
c8afcec73d Back to -dev after ipx 1.4.0, ipx-directory 1.3.0 and ipx-web 1.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:46:58 +00:00
58 changed files with 2013 additions and 387 deletions

View File

@@ -29,6 +29,8 @@ $Q traces '<TraceQL>' [since] # slowest first
$Q spans '<TraceQL>' [since] # the matching spans, each with its own time, 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 trace <trace id> # one trace as a tree, with its log lines
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since` $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. `since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.

View File

@@ -7,11 +7,14 @@
query.py spans '<TraceQL query>' [since] the matching spans, each with its own time 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 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 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 `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. 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" NET = "monitoring_default"
@@ -24,11 +27,108 @@ def fetch(url):
return json.loads(out.stdout) 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): def seconds(since):
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]] return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
def main(): def main():
if sys.argv[1:] == ["flame", "--check"]:
return check_fold()
if len(sys.argv) < 3: if len(sys.argv) < 3:
sys.exit(__doc__) sys.exit(__doc__)
kind, q = sys.argv[1], sys.argv[2] kind, q = sys.argv[1], sys.argv[2]
@@ -82,10 +182,24 @@ def main():
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")} 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"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
print(f"-- {len(rows)} series", file=sys.stderr) 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": elif kind == "trace":
r = fetch(f"http://tempo:3200/api/traces/{q}") spans = trace_spans(fetch(f"http://tempo:3200/api/traces/{q}"))
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
kids = {} kids = {}
for sp in spans: for sp in spans:
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp) kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)

View File

@@ -396,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 * They are the open issues in Gitea, not a list here: a limitation known and left in place is an
issue left open. issue left open.
## Styleguide
* Best effort to adhere to the rust style guide found at /src/styleguide/rustguide.md
<!-- rtk-instructions v2 --> <!-- rtk-instructions v2 -->
# Command output # Command output

19
Cargo.lock generated
View File

@@ -800,9 +800,9 @@ dependencies = [
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [ dependencies = [
"cmov", "cmov",
] ]
@@ -1860,7 +1860,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "1.4.0" version = "1.5.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
@@ -1894,7 +1894,7 @@ dependencies = [
[[package]] [[package]]
name = "ipx-directory" name = "ipx-directory"
version = "1.3.0" version = "1.4.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
@@ -1930,6 +1930,8 @@ dependencies = [
"anyhow", "anyhow",
"atom_syndication", "atom_syndication",
"chrono", "chrono",
"http",
"hyper-util",
"opml", "opml",
"pulldown-cmark", "pulldown-cmark",
"quick-xml 0.42.0", "quick-xml 0.42.0",
@@ -1937,6 +1939,7 @@ dependencies = [
"rss", "rss",
"serde_json", "serde_json",
"tokio", "tokio",
"tower",
"tracing", "tracing",
"url", "url",
] ]
@@ -3625,9 +3628,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.15" version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
@@ -5108,9 +5111,9 @@ dependencies = [
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.9" version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]

View File

@@ -27,6 +27,8 @@ opentelemetry_sdk = { version = "0.33", default-features = false, features = ["t
opml = "1.1.6" opml = "1.1.6"
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] } pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
mimalloc = "0.1.52" mimalloc = "0.1.52"
http = "1.5.0"
hyper-util = { version = "0.1.20", features = ["client-legacy"] }
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] } quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }

View File

@@ -47,7 +47,9 @@ cargo build --release
./target/release/ipx daemon ./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 ```sh
echo -n 'a good password' | ipx user passwd admin echo -n 'a good password' | ipx user passwd admin

View File

@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.3.0] - 2026-10-19
### Added ### Added
@@ -72,7 +85,8 @@ 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). - `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). - 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.3.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.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.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.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx-directory" name = "ipx-directory"
version = "1.3.0" version = "1.4.0"
edition.workspace = true edition.workspace = true
publish = false publish = false

View File

@@ -23,6 +23,10 @@ pub struct Config {
/// left out, does neither. /// left out, does neither.
#[serde(default)] #[serde(default)]
pub apple_country: String, 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)] #[serde(default)]
pub tokens: Vec<TokenCfg>, pub tokens: Vec<TokenCfg>,
} }

View File

@@ -127,20 +127,54 @@ pub struct Directory {
client: reqwest::Client, client: reqwest::Client,
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute. /// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
wake: tokio::sync::Notify, 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>; pub type Shared = Arc<Directory>;
impl 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. // No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(ipx_feed::FEED_SILENCE) .read_timeout(ipx_feed::FEED_SILENCE)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION"))) .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()?; .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. /// Takes the tokens of a config file read again, and drops the snapshots of any that went.
@@ -492,7 +526,7 @@ mod tests {
sha256: config::hash(&format!("secret-{n}")), sha256: config::hash(&format!("secret-{n}")),
}) })
.collect(); .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) (router(dir.clone()), dir)
} }
@@ -541,6 +575,26 @@ mod tests {
assert_eq!(got["accepted"], 1); 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] #[tokio::test]
async fn answers_come_compressed_as_the_caller_asks() { async fn answers_come_compressed_as_the_caller_asks() {
let (app, _) = app().await; let (app, _) = app().await;
@@ -635,7 +689,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_revoked_token_loses_its_access_and_its_counts() { 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}")) }; 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(); dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
let app = router(dir.clone()); let app = router(dir.clone());

View File

@@ -102,7 +102,7 @@ async fn serve(path: &std::path::Path) -> Result<()> {
.filter(|u| !u.trim().is_empty()) .filter(|u| !u.trim().is_empty())
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?; .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 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 dropped = dir.set_tokens(tokens).await?;
let listener = let listener =

View File

@@ -58,17 +58,17 @@ impl Filter {
} }
async fn home(State(dir): State<Shared>) -> Response { 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, Ok(a) => a,
Err(e) => return failed(e), Err(e) => return failed(e),
}; };
let mut top = all.clone(); let mut top = (*all).clone();
top.sort_by(|a, b| { top.sort_by(|a, b| {
b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase())) b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))
}); });
top.truncate(10); top.truncate(10);
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new(); 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); cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
} }
let mut b = String::new(); let mut b = String::new();
@@ -123,8 +123,8 @@ 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. /// 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 { 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 { let mut ls: Vec<Listing> = match dir.pages_list().await {
Ok(a) => a.into_iter().filter(|l| pick(l)).collect(), Ok(a) => a.iter().filter(|l| pick(l)).cloned().collect(),
Err(e) => return failed(e), Err(e) => return failed(e),
}; };
if ls.is_empty() && here.starts_with("/category/") { if ls.is_empty() && here.starts_with("/category/") {

View File

@@ -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). /// 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; 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 { pub struct Store {
db: DatabaseConnection, db: DatabaseConnection,
} }
@@ -221,7 +224,9 @@ impl Store {
continue; continue;
} }
// The same feed twice in one snapshot counts once, at its larger number. // 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 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())); let slot = keep.entry(id).or_insert((url, n, path.clone()));
slot.1 = slot.1.max(n); slot.1 = slot.1.max(n);
@@ -548,6 +553,17 @@ pub(crate) mod tests {
assert!(normalise("not a url").is_none()); assert!(normalise("not a url").is_none());
} }
#[tokio::test]
async fn no_count_an_instance_sends_breaks_the_listing() {
let s = Store::scratch().await;
s.snapshot("a", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
s.snapshot("b", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
fetch_all(&s).await;
assert_eq!(s.list().await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS, "summed, not overflowed (#342)");
assert_eq!(s.popular(10).await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS);
assert!(s.instances().await.is_ok());
}
#[tokio::test] #[tokio::test]
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() { async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
let s = Store::scratch().await; let s = Store::scratch().await;

View File

@@ -8,12 +8,15 @@ edition.workspace = true
anyhow.workspace = true anyhow.workspace = true
atom_syndication.workspace = true atom_syndication.workspace = true
chrono.workspace = true chrono.workspace = true
http.workspace = true
hyper-util.workspace = true
opml.workspace = true opml.workspace = true
pulldown-cmark.workspace = true pulldown-cmark.workspace = true
quick-xml.workspace = true quick-xml.workspace = true
reqwest.workspace = true reqwest.workspace = true
rss.workspace = true rss.workspace = true
serde_json.workspace = true serde_json.workspace = true
tower.workspace = true
tracing.workspace = true tracing.workspace = true
url.workspace = true url.workspace = true

269
crates/feed/src/guard.rs Normal file
View 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());
}
}

View File

@@ -1,6 +1,8 @@
//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces //! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
//! FeedData.__getFeed / __getEntries. //! FeedData.__getFeed / __getEntries.
pub mod guard;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use reqwest::StatusCode; use reqwest::StatusCode;
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED}; use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
@@ -175,6 +177,15 @@ pub async fn fetch(
return Ok((Fetched::NotModified, moved)); return Ok((Fetched::NotModified, moved));
} }
if !status.is_success() { 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. // The original surfaced 401/407 specially; the code is enough for a UI to switch on.
return Err(anyhow!("HTTP {status}")); return Err(anyhow!("HTTP {status}"));
} }
@@ -191,6 +202,57 @@ pub async fn fetch(
Err(anyhow!("error following redirect for url ({url}): too many redirects")) 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 /// 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. /// it is, and whether there is a new address to switch to.
pub struct Failure { pub struct Failure {
@@ -297,10 +359,37 @@ pub fn is_opml(bytes: &[u8]) -> bool {
text.contains("<opml") 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. /// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> { pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
let text = String::from_utf8_lossy(bytes); 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![]; let mut out = vec![];
collect_outlines(&doc.body.outlines, &mut out); collect_outlines(&doc.body.outlines, &mut out);
Ok(out) Ok(out)
@@ -320,7 +409,7 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
/// The <head><title> of an OPML document. /// The <head><title> of an OPML document.
pub fn opml_title(bytes: &[u8]) -> Option<String> { pub fn opml_title(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes); let text = String::from_utf8_lossy(bytes);
let doc = opml::OPML::from_str(&text).ok()?; let doc = read_opml(&text).ok()?;
doc.head.and_then(|h| h.title).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()) doc.head.and_then(|h| h.title).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
} }
@@ -363,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 /// creator's name, by number, or with no creator at all -- and the token and show are what
/// identify it. /// identify it.
pub fn same_feed(a: &str, b: &str) -> bool { 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). /// A Patreon creator's name and shows, each show as (title, feed URL).
@@ -432,6 +525,11 @@ fn parse_patreon_shows(json: &[u8]) -> Result<PatreonShows> {
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`. /// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
#[tracing::instrument(skip_all, fields(bytes = bytes.len()))] #[tracing::instrument(skip_all, fields(bytes = bytes.len()))]
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> { 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) { match rss::Channel::read_from(bytes) {
Ok(ch) => Ok(from_rss(ch, bytes)), Ok(ch) => Ok(from_rss(ch, bytes)),
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) { Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
@@ -572,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()) 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 /// 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 /// 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 /// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
@@ -587,6 +688,10 @@ pub async fn prefer_https(
let secure = format!("https://{rest}"); let secure = format!("https://{rest}");
let ok = match known.get(&host) { let ok = match known.get(&host) {
Some(ok) => *ok, 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 => { None => {
let ok = is_image(client, &secure).await; let ok = is_image(client, &secure).await;
known.insert(host, ok); known.insert(host, ok);
@@ -1292,6 +1397,29 @@ const PAID_HOSTS: &[&str] = &["patreon.com", "supercast.com", "supportingcast.fm
#[cfg(test)] #[cfg(test)]
mod tests { 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&#8217;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] #[test]
fn redact_drops_what_carries_a_credential() { fn redact_drops_what_carries_a_credential() {
assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x"); assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x");
@@ -1721,6 +1849,32 @@ Some **bold** and [a link](https://x.example/).</description></item>
assert_eq!((p1.season, p1.episode), (Some(8), Some(1))); 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] #[test]
fn opml_is_recognised_and_its_feeds_listed() { fn opml_is_recognised_and_its_feeds_listed() {
let xml = br#"<opml version="2.0"><head><title>My Subscriptions</title></head><body> let xml = br#"<opml version="2.0"><head><title>My Subscriptions</title></head><body>

View File

@@ -12,6 +12,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.4.0] - 2026-10-19
### Added ### Added
@@ -885,7 +923,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout. - Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`. - `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.4.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.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.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.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "1.4.0" version = "1.5.0"
edition.workspace = true edition.workspace = true
publish = false publish = false

View File

@@ -31,8 +31,10 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong /// 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). /// 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. /// 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(|| { static CHECKS: std::sync::LazyLock<std::sync::Arc<tokio::sync::Semaphore>> = std::sync::LazyLock::new(|| {
tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1))) 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 /// What a name that is not an account is checked against, so that refusing it takes as long as
@@ -45,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 /// or it has no password: false either way, in the same time. Off the async workers, and a few at
/// a time; see `CHECKS`. /// a time; see `CHECKS`.
pub async fn check_password(password: String, stored: Option<String>) -> bool { pub async fn check_password(password: String, stored: Option<String>) -> bool {
let Ok(_turn) = CHECKS.acquire().await else { return false }; let Ok(turn) = CHECKS.clone().acquire_owned().await else { return false };
tokio::task::spawn_blocking(move || match stored { // The turn goes with the check, not with this future: a client that hung up dropped the
Some(h) => verify_password(&password, &h), // future, freeing the turn while the check ran on, so disconnecting sign-ins ran any number
None => { // at once (#324).
verify_password(&password, &DECOY); tokio::task::spawn_blocking(move || {
false let _turn = turn;
match stored {
Some(h) => verify_password(&password, &h),
None => {
verify_password(&password, &DECOY);
false
}
} }
}) })
.await .await

View File

@@ -56,6 +56,10 @@ pub struct General {
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a /// 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. /// month of production's feeds was about 8,000.
pub signal_days: u64, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -215,6 +219,7 @@ impl Default for General {
directory_share: false, directory_share: false,
signals_on_server: false, signals_on_server: false,
signal_days: 7, signal_days: 7,
fetch_internal: vec![],
} }
} }
} }
@@ -295,6 +300,27 @@ impl Feed {
pub fn auth(&self) -> Option<(&str, Option<String>)> { pub fn auth(&self) -> Option<(&str, Option<String>)> {
self.username.as_deref().map(|u| (u, self.password())) 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 { impl Config {
@@ -496,12 +522,12 @@ pub fn slug(text: &str) -> String {
} }
/// `slug`, with a numeric suffix if that id is taken. /// `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); let base = slug(text);
if !taken.contains_key(&base) { if !taken(&base) {
return 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. /// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
@@ -524,6 +550,22 @@ fn expand_tilde(p: &Path) -> PathBuf {
mod tests { mod tests {
use super::*; 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] #[test]
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() { fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
let cfg: Config = toml::from_str( let cfg: Config = toml::from_str(
@@ -690,7 +732,7 @@ mod tests {
private: false, private: false,
}, },
); );
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2"); assert_eq!(unique_slug("The Daily", |s| taken.contains_key(s)), "the-daily-2");
} }
#[test] #[test]

View File

@@ -86,6 +86,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
// the expression it is asked by. Without it, each wake read all of entries, 605 MB, 566 ms // the expression it is asked by. Without it, each wake read all of entries, 605 MB, 566 ms
// to find nothing (#274). // to find nothing (#274).
"CREATE INDEX IF NOT EXISTS entries_recent ON entries (feed_id, (coalesce(published, first_seen)))", "CREATE INDEX IF NOT EXISTS entries_recent ON entries (feed_id, (coalesce(published, first_seen)))",
// Unread lists read a feed's items from `subscriptions.unread_from` on (#347).
"CREATE INDEX IF NOT EXISTS entries_seen ON entries (feed_id, first_seen)",
] { ] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
} }
@@ -166,6 +168,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
let sql = "ALTER TABLE signals ADD COLUMN icon TEXT"; let sql = "ALTER TABLE signals ADD COLUMN icon TEXT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
} }
if orm.execute_unprepared("SELECT unread_from FROM subscriptions LIMIT 0").await.is_err() {
let sql = "ALTER TABLE subscriptions ADD COLUMN unread_from BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() { if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT"; let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
@@ -185,6 +191,21 @@ pub fn location() -> String {
/// The Postgres connection option that keeps notices from being sent at all; see `connect`. /// The Postgres connection option that keeps notices from being sent at all; see `connect`.
const QUIET: &str = "options=-c%20client_min_messages%3Dwarning"; const QUIET: &str = "options=-c%20client_min_messages%3Dwarning";
/// How many of one subscription's items, `sub`, are unread and not hidden: a query of its own
/// for each, so Postgres sees the feed and where its unread begins as constants and looks up the
/// few items since. Joined across every feed, it guessed a third of them and hashed all of the
/// person's read state, 22,949 rows for 44 unread (#352).
macro_rules! unread_in_sub {
() => {
"SELECT count(*) FROM entries e
LEFT JOIN entry_state s ON s.user_id = sub.user_id AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = sub.feed_id AND e.first_seen >= coalesce(sub.unread_from, 0)
AND NOT coalesce(s.read, false)
AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = sub.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid)"
};
}
impl Db { impl Db {
/// The connection pool as it stands, for /metrics (#194): connections open, those of them /// The connection pool as it stands, for /metrics (#194): connections open, those of them
/// idle, and the most it will open. /// idle, and the most it will open.
@@ -521,50 +542,52 @@ impl Db {
// Of this person's feeds only, as the unread count is: counting every item of every feed on // Of this person's feeds only, as the unread count is: counting every item of every feed on
// the server, 1534 feeds for someone reading 146, was 184-316 ms, 70% of the feed list's // the server, 1534 feeds for someone reading 146, was 184-316 ms, 70% of the feed list's
// time (#165). The Directory reads this for every feed's title and artwork, not a count. // time (#165). The Directory reads this for every feed's title and artwork, not a count.
// Five queries that need nothing of each other, run at once: one after another they were
// 59 ms of a 67 ms feed list, on a pool of idle connections (#346).
let entries = counts( let entries = counts(
"entries", "entries",
"SELECT e.feed_id, count(*) AS n FROM entries e "SELECT e.feed_id, count(*) AS n FROM entries e
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id
WHERE (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) GROUP BY e.feed_id", WHERE (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) GROUP BY e.feed_id",
vec![user_id.into(), one()], vec![user_id.into(), one()],
) );
.await?;
let downloaded = counts( let downloaded = counts(
"downloaded", "downloaded",
"SELECT x.feed_id, count(*) AS n FROM enclosures x "SELECT x.feed_id, count(*) AS n FROM enclosures x
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = x.feed_id JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = x.feed_id
WHERE x.path IS NOT NULL AND (CAST($2 AS TEXT) IS NULL OR x.feed_id = $2) GROUP BY x.feed_id", WHERE x.path IS NOT NULL AND (CAST($2 AS TEXT) IS NULL OR x.feed_id = $2) GROUP BY x.feed_id",
vec![user_id.into(), one()], vec![user_id.into(), one()],
) );
.await?;
let unread = counts( let unread = counts(
"unread", "unread",
"SELECT e.feed_id, count(*) AS n FROM entries e concat!(
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id "SELECT * FROM (SELECT sub.feed_id, (",
LEFT JOIN entry_state s unread_in_sub!(),
ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid ") AS n FROM subscriptions sub
WHERE NOT coalesce(s.read, false) WHERE sub.user_id = $1 AND (CAST($2 AS TEXT) IS NULL OR sub.feed_id = $2)) x
AND (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) WHERE n > 0"
AND NOT EXISTS (SELECT 1 FROM hidden h ),
WHERE h.user_id = $1 AND h.feed_id = e.feed_id AND h.guid = e.guid)
GROUP BY e.feed_id",
vec![user_id.into(), one()], vec![user_id.into(), one()],
) );
.await?; let blocked = tracing::Instrument::instrument(
let mut blocked: HashMap<String, Vec<String>> = tracing::Instrument::instrument(
blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm), blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm),
step("blocked"), step("blocked"),
) );
.await?
.into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect();
let mut rows = feeds::Entity::find(); let mut rows = feeds::Entity::find();
if let Some(id) = only { if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id)); rows = rows.filter(feeds::Column::Id.eq(id));
} }
Ok(tracing::Instrument::instrument(rows.all(&self.orm), step("feeds")) let rows = tracing::Instrument::instrument(rows.all(&self.orm), step("feeds"));
.await? let (entries, downloaded, unread, blocked, rows) = tokio::try_join!(
entries,
downloaded,
unread,
async { blocked.await.map_err(anyhow::Error::from) },
async { rows.await.map_err(anyhow::Error::from) },
)?;
let mut blocked: HashMap<String, Vec<String>> =
blocked.into_iter().map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default())).collect();
Ok(rows
.into_iter() .into_iter()
.map(|f| { .map(|f| {
let id = f.id.clone(); let id = f.id.clone();
@@ -1232,8 +1255,20 @@ fn entries_from(
} }
None => "true".into(), None => "true".into(),
}; };
// Unread starts from where this person's unread begins in each feed, so it reads the few
// items since, not every one: 11,727 read for 44 unread, 32 ms (#347). Every list is of
// subscribed feeds, so the join takes nothing away.
let since = if filter == Filter::Unread {
format!(
"JOIN subscriptions w ON w.user_id = {user} AND w.feed_id = e.feed_id
AND e.first_seen >= coalesce(w.unread_from, 0)"
)
} else {
String::new()
};
format!( format!(
"FROM entries e "FROM entries e
{since}
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE {scope} AND {} AND {search} AND {}", WHERE {scope} AND {} AND {search} AND {}",
filter.sql(), filter.sql(),
@@ -1468,8 +1503,7 @@ impl Db {
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
ORDER BY {order}" ORDER BY {order}"
); );
let mut rows = self let mut rows = tracing::Instrument::instrument(self.rows(&sql, a.0), step("page"))
.rows(&sql, a.0)
.await? .await?
.iter() .iter()
.map(|r| { .map(|r| {
@@ -1499,11 +1533,18 @@ impl Db {
// Only the guids on this page, so a feed with thousands of entries stays cheap. A page // Only the guids on this page, so a feed with thousands of entries stays cheap. A page
// can span feeds, so each file is matched to its row by feed as well as guid, below. // can span feeds, so each file is matched to its row by feed as well as guid, below.
let encs = enclosures::Entity::find() // The page's feeds too: the index is (feed_id, guid), and by guid alone Postgres read
.filter(enclosures::Column::Guid.is_in(rows.iter().map(|r| r.guid.clone()))) // all of it, 587k rows and 250 ms for a page of 50 (#344).
.order_by_asc(enclosures::Column::Id) let feeds: std::collections::BTreeSet<&str> = rows.iter().map(|r| r.feed_id.as_str()).collect();
.all(&self.orm) let encs = tracing::Instrument::instrument(
.await?; enclosures::Entity::find()
.filter(enclosures::Column::FeedId.is_in(feeds.into_iter().map(str::to_owned)))
.filter(enclosures::Column::Guid.is_in(rows.iter().map(|r| r.guid.clone())))
.order_by_asc(enclosures::Column::Id)
.all(&self.orm),
step("enclosures"),
)
.await?;
for enc in encs { for enc in encs {
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid && r.feed_id == enc.feed_id) { if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid && r.feed_id == enc.feed_id) {
row.enclosures.push(EncRow::from(enc)); row.enclosures.push(EncRow::from(enc));
@@ -1532,7 +1573,23 @@ impl Db {
search: Option<&str>, search: Option<&str>,
) -> Result<i64> { ) -> Result<i64> {
let mut a = Args::default(); let mut a = Args::default();
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feeds, filter, search, None, None)); let sql = if filter == Filter::Unread && search.is_none() {
// Feed by feed, as the feed list counts (#352); the same feeds as `entries_from`.
let user = a.p(user_id);
let scope = match feeds {
Some([f]) => format!("sub.feed_id = {}", a.p(*f)),
Some([]) => "false".into(),
Some(fs) => format!("sub.feed_id IN ({})", fs.iter().map(|f| a.p(*f)).collect::<Vec<_>>().join(", ")),
None => shown_in("hide_from_all", "sub"),
};
format!(
"SELECT CAST(coalesce(sum(n), 0) AS BIGINT) AS n
FROM (SELECT ({}) AS n FROM subscriptions sub WHERE sub.user_id = {user} AND {scope}) x",
unread_in_sub!()
)
} else {
format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feeds, filter, search, None, None))
};
let r = self.rows(&sql, a.0).await?; let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?) Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
} }
@@ -2057,8 +2114,14 @@ impl Db {
Ok(()) Ok(())
} }
/// Signs the account out everywhere, too: a password is changed because someone else may
/// know it, and a session they signed in with stayed good, every use putting off its expiry
/// (#334). API tokens stay; they are listed and removed in Settings.
pub async fn set_password(&self, id: i64, hash: &str) -> Result<()> { pub async fn set_password(&self, id: i64, hash: &str) -> Result<()> {
self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() }).await self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() })
.await?;
sessions::Entity::delete_many().filter(sessions::Column::UserId.eq(id)).exec(&self.orm).await?;
Ok(())
} }
pub async fn set_admin(&self, id: i64, admin: bool) -> Result<()> { pub async fn set_admin(&self, id: i64, admin: bool) -> Result<()> {
@@ -2262,9 +2325,37 @@ impl Db {
vec![user_id.into(), feed_id.into(), guid.into(), on.into()], vec![user_id.into(), feed_id.into(), guid.into(), on.into()],
) )
.await?; .await?;
// An item marked unread again may be older than where this person's unread was known to
// start, so that is forgotten until the next reap works it out (`advance_unread_from`).
if matches!(field, EntryFlag::Read) && !on {
self.exec(
"UPDATE subscriptions SET unread_from = NULL
WHERE user_id = $1 AND feed_id = $2 AND unread_from IS NOT NULL",
vec![user_id.into(), feed_id.into()],
)
.await?;
}
Ok(()) Ok(())
} }
/// Moves each subscription's `unread_from` up to its oldest unread item, hidden ones included,
/// since a block list can be lifted. No more than an hour ago: an item a scan is storing now,
/// not yet visible to this statement, is first seen about now. Returns how many moved.
pub async fn advance_unread_from(&self) -> Result<u64> {
let cutoff = now() - 3600;
self.exec(
"UPDATE subscriptions SET unread_from = (
SELECT CASE WHEN min(e.first_seen) < $1 THEN min(e.first_seen) ELSE $1 END
FROM entries e
LEFT JOIN entry_state s
ON s.user_id = subscriptions.user_id AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = subscriptions.feed_id AND NOT coalesce(s.read, false))
WHERE true",
vec![cutoff.into()],
)
.await
}
/// How many files this feed has on disk. Decides whether a feed dropped from an OPML /// How many files this feed has on disk. Decides whether a feed dropped from an OPML
/// can be removed or must be kept. /// can be removed or must be kept.
pub async fn downloaded_count(&self, feed_id: &str) -> Result<i64> { pub async fn downloaded_count(&self, feed_id: &str) -> Result<i64> {
@@ -2788,23 +2879,25 @@ impl Db {
let mut a = Args::default(); let mut a = Args::default();
let user = a.p(user_id); let user = a.p(user_id);
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default(); let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
// The items it shows, and of those the unread: what its list counts, by the same rules. // The items it shows, and of those the unread: what its list counts, by the same rules. One
let hits = |unread: &str| { // pass for both; they were two copies of the same subquery (#348).
format!(
"(SELECT count(*) FROM signal_verdicts v
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {} AND {})",
not_hidden(&user),
shown_in("hide_from_signals", "s")
)
};
let sql = format!( let sql = format!(
"SELECT g.id, g.name, g.keywords, g.description, g.icon, {} AS items, {} AS unread "SELECT g.id, g.name, g.keywords, g.description, g.icon,
FROM signals g WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id", coalesce(c.items, 0) AS items, coalesce(c.unread, 0) AS unread
hits("true"), FROM signals g
hits("NOT coalesce(st.read, false)") LEFT JOIN (SELECT v.signal_id, count(*) AS items,
sum(CASE WHEN NOT coalesce(st.read, false) THEN 1 ELSE 0 END) AS unread
FROM signal_verdicts v
JOIN signals vg ON vg.id = v.signal_id
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
JOIN subscriptions s ON s.user_id = vg.user_id AND s.feed_id = v.feed_id
LEFT JOIN entry_state st
ON st.user_id = vg.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
WHERE vg.user_id = {user} AND v.hit AND {} AND {}
GROUP BY v.signal_id) c ON c.signal_id = g.id
WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id",
not_hidden(&user),
shown_in("hide_from_signals", "s")
); );
self.rows(&sql, a.0) self.rows(&sql, a.0)
.await? .await?
@@ -3001,6 +3094,67 @@ pub fn now() -> i64 {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn unread_starts_where_it_was_worked_out_and_loses_nothing() {
let db = Db::memory().await.unwrap();
let me = db.create_user("me", None, false).await.unwrap();
db.exec_for_test(
"INSERT INTO feeds (id, url) VALUES ('f', 'http://x/f.xml');
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','old',100),('f','mid',200),('f','new',300);",
)
.await
.unwrap();
db.subscribe(me, "f").await.unwrap();
db.set_entry_flag(me, "f", "old", EntryFlag::Read, true).await.unwrap();
let unread = async || {
let order = order_sql("published", "desc");
let rows = db.entries_of(me, None, Filter::Unread, None, 0, 50, &order).await.unwrap();
let mut guids: Vec<String> = rows.into_iter().map(|r| r.guid).collect();
guids.sort();
let count = db.count_of(me, None, Filter::Unread, None).await.unwrap();
let listed = db.feed_list(me, None).await.unwrap()["f"].unread;
assert_eq!(count, guids.len() as i64);
assert_eq!(listed, count, "the feed list counts what the list shows");
guids
};
assert_eq!(unread().await, ["mid", "new"]);
db.advance_unread_from().await.unwrap();
let from = async || -> Option<i64> {
db.rows("SELECT unread_from FROM subscriptions", vec![]).await.unwrap()[0]
.try_get("", "unread_from")
.unwrap()
};
assert_eq!(from().await, Some(200), "the oldest unread");
assert_eq!(unread().await, ["mid", "new"], "nothing unread goes missing (#347)");
// An old item marked unread again comes back, though it is older than where unread began.
db.set_entry_flag(me, "f", "old", EntryFlag::Read, false).await.unwrap();
assert_eq!(unread().await, ["mid", "new", "old"]);
// With everything read, it starts no later than an hour ago.
db.mark_all_read(me, &["f".into()]).await.unwrap();
db.advance_unread_from().await.unwrap();
assert!(from().await.is_some_and(|t| t <= now() - 3600));
assert!(unread().await.is_empty());
}
#[tokio::test]
async fn a_new_password_signs_the_account_out_everywhere_but_keeps_its_tokens() {
let db = Db::memory().await.unwrap();
let (me, you) =
(db.create_user("me", None, false).await.unwrap(), db.create_user("you", None, false).await.unwrap());
db.create_session(me, "mine").await.unwrap();
db.create_session(you, "yours").await.unwrap();
db.create_api_token(me, "script", "hash").await.unwrap();
db.set_password(me, "new-hash").await.unwrap();
assert!(db.session_user("mine", 3600).await.unwrap().is_none(), "a stolen cookie stops working (#334)");
assert!(db.session_user("yours", 3600).await.unwrap().is_some(), "nobody else is signed out");
assert!(db.api_token_user("hash").await.unwrap().is_some());
}
#[tokio::test] #[tokio::test]
async fn deadair_looks_at_a_file_once_per_download_and_counts_an_episode_once() { async fn deadair_looks_at_a_file_once_per_download_and_counts_an_episode_once() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();
@@ -3586,6 +3740,8 @@ mod tests {
(1,'f','b','u1','/tmp/b','done'); (1,'f','b','u1','/tmp/b','done');
-- Read and starred belong to a person now, so say which one. -- Read and starred belong to a person now, so say which one.
INSERT INTO users (id, name, is_admin) VALUES (7,'reader',true); INSERT INTO users (id, name, is_admin) VALUES (7,'reader',true);
-- Every list is of feeds its reader subscribes to; Unread joins the subscription.
INSERT INTO subscriptions (user_id, feed_id) VALUES (7,'f');
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
(7,'f','b',true,false,0), (7,'f','b',true,false,0),
(7,'f','c',true,true,0), (7,'f','c',true,true,0),

View File

@@ -70,7 +70,7 @@ async fn call<T: DeserializeOwned>(
) -> Result<T, DirError> { ) -> Result<T, DirError> {
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?; let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
let mut req = ctx let mut req = ctx
.client .local_client
.request(method, format!("{base}{path}")) .request(method, format!("{base}{path}"))
.bearer_auth(token) .bearer_auth(token)
.timeout(std::time::Duration::from_secs(20)); .timeout(std::time::Duration::from_secs(20));

View File

@@ -133,8 +133,8 @@ pub async fn download(
mut on_progress: impl FnMut(u64, Option<u64>), mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<Downloaded> { ) -> Result<Downloaded> {
let mut req = client.get(url); let mut req = client.get(url);
if let Some(user) = &feed_cfg.username { if let Some((user, password)) = feed_cfg.auth_for(url) {
req = req.basic_auth(user, feed_cfg.password()); req = req.basic_auth(user, password);
} }
let resp = req.send().await.context("connecting")?; let resp = req.send().await.context("connecting")?;
if !resp.status().is_success() { if !resp.status().is_success() {

View File

@@ -230,6 +230,10 @@ pub mod subscriptions {
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289). /// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
#[sea_orm(default_value = false)] #[sea_orm(default_value = false)]
pub hide_from_signals: bool, 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!(); owned_by_user!();

View File

@@ -104,6 +104,38 @@ pub enum Event {
} }
impl Event { 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 { pub fn is_terminal(&self) -> bool {
matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. }) matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. })
} }
@@ -458,6 +490,31 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
mod tests { mod tests {
use super::*; 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] #[test]
fn a_feed_not_due_stays_off_the_live_updates() { fn a_feed_not_due_stays_off_the_live_updates() {
let (tx, mut rx) = broadcast::channel(4); let (tx, mut rx) = broadcast::channel(4);

View File

@@ -118,9 +118,35 @@ enum UserCmd {
Rename { name: String, new_name: String }, Rename { name: String, new_name: String },
} }
/// What a brand new database starts with, so there is always a way in. Announced loudly /// The first admin's password: `IPX_ADMIN_PASSWORD` when set, or a random one written to
/// in the log, and the first thing the settings page nags about. /// `admin-password` beside config.toml, readable by the daemon's user alone. Every install began
const DEFAULT_PASSWORD: &str = "ipodderx"; /// as admin / ipodderx, published in the README, and nothing made anyone change it (#321).
/// Returns the password and where it can be found, for the log, which never shows it.
fn first_password(config_dir: &std::path::Path, from_env: Option<String>) -> Result<(String, String)> {
if let Some(p) = from_env.filter(|p| !p.is_empty()) {
return Ok((p, "with the password in IPX_ADMIN_PASSWORD".into()));
}
let password = crate::auth::new_session_token()[..24].to_owned();
let file = config_dir.join("admin-password");
write_private(&file, &password)?;
Ok((password, format!("with the password in {}", file.display())))
}
/// Writes a secret only its owner may read: created 0600, never readable for a moment between.
fn write_private(file: &std::path::Path, text: &str) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
std::fs::create_dir_all(file.parent().unwrap_or(std::path::Path::new(".")))?;
let _ = std::fs::remove_file(file);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(file)
.with_context(|| format!("writing {}", file.display()))?;
f.write_all(text.as_bytes())?;
Ok(())
}
/// Everything a command needs. One per process. /// Everything a command needs. One per process.
pub struct Ctx { pub struct Ctx {
@@ -129,8 +155,12 @@ pub struct Ctx {
/// across an await. /// across an await.
pub cfg: std::sync::RwLock<std::sync::Arc<config::Config>>, pub cfg: std::sync::RwLock<std::sync::Arc<config::Config>>,
pub db: db::Db, pub db: db::Db,
/// For anything a feed or a person names: refuses internal addresses (#320).
pub client: reqwest::Client, pub client: reqwest::Client,
/// For feeds only: follows no redirects, so `feed::fetch` sees each hop and can tell a feed /// For the services this server is set up to call, which are on its own network by design:
/// the directory, as `http://ipx-directory:8100`. Never for an address a feed or a person names.
pub local_client: reqwest::Client,
/// For feeds only, refusing internal addresses as `client` does: follows no redirects, so `feed::fetch` sees each hop and can tell a feed
/// that moved for good from one sent elsewhere for now. /// that moved for good from one sent elsewhere for now.
pub feed_client: reqwest::Client, pub feed_client: reqwest::Client,
pub out: Emitter, pub out: Emitter,
@@ -235,14 +265,15 @@ impl Ctx {
/// The config as it stands, to change and then keep with `store_cfg`; any other change waits /// The config as it stands, to change and then keep with `store_cfg`; any other change waits
/// until then. /// until then.
pub async fn edit_cfg(&self) -> CfgEdit<'_> { pub async fn edit_cfg(&self) -> CfgEdit<'_> {
let held = self.cfg_write.lock().await; // A step of its own: waiting here is waiting on another change to the catalogue.
let held = tracing::Instrument::instrument(self.cfg_write.lock(), db::step("cfg_lock")).await;
CfgEdit { cfg: (*self.cfg()).clone(), _held: held } CfgEdit { cfg: (*self.cfg()).clone(), _held: held }
} }
/// Keeps a changed catalogue or server settings: in the database, and for everything running /// Keeps a changed catalogue or server settings: in the database, and for everything running
/// here from now on. What config.toml's save and reload did, before the database held them. /// here from now on. What config.toml's save and reload did, before the database held them.
pub async fn store_cfg(&self, edit: CfgEdit<'_>) -> Result<()> { pub async fn store_cfg(&self, edit: CfgEdit<'_>) -> Result<()> {
self.db.store_config(&edit.cfg).await?; tracing::Instrument::instrument(self.db.store_config(&edit.cfg), db::step("store_config")).await?;
self.set_cfg(edit.cfg); self.set_cfg(edit.cfg);
Ok(()) Ok(())
} }
@@ -352,6 +383,7 @@ async fn main() -> Result<()> {
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } } Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
); );
let (events, _) = broadcast::channel(1024); let (events, _) = broadcast::channel(1024);
let guard = feed::guard::Guard::new(feed::guard::Allowed::parse(&cfg.general.fetch_internal)?);
let ctx = Arc::new(Ctx { let ctx = Arc::new(Ctx {
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)), cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
db, db,
@@ -362,12 +394,18 @@ async fn main() -> Result<()> {
// Two minutes without a byte is a stalled download, however long it is: unbounded, one // Two minutes without a byte is a stalled download, however long it is: unbounded, one
// held the worker, and every scan behind it, for good, while the daemon read healthy (#152). // held the worker, and every scan behind it, for good, while the daemon read healthy (#152).
.read_timeout(std::time::Duration::from_secs(120)) .read_timeout(std::time::Duration::from_secs(120))
.connector_layer(guard.clone())
.build()?,
local_client: reqwest::Client::builder()
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(10))
.build()?, .build()?,
feed_client: reqwest::Client::builder() feed_client: reqwest::Client::builder()
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(feed::FEED_SILENCE) .read_timeout(feed::FEED_SILENCE)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.connector_layer(guard)
.build()?, .build()?,
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() }, out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
torrents: tokio::sync::OnceCell::new(), torrents: tokio::sync::OnceCell::new(),
@@ -502,7 +540,13 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
let name = name.trim().to_ascii_lowercase(); let name = name.trim().to_ascii_lowercase();
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?; ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?;
println!("password changed for {name}"); println!("password changed for {name}, and every browser signed in as {name} signed out");
let tokens = ctx.db.api_tokens(user.id).await?.len();
if tokens > 0 {
println!(
"{tokens} API token(s) of {name}'s still work: remove any that may be someone else's in Settings"
);
}
Ok(()) Ok(())
} }
UserCmd::Rename { name, new_name } => { UserCmd::Rename { name, new_name } => {
@@ -568,9 +612,12 @@ async fn reload(ctx: &Ctx) -> Result<()> {
/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with /// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with
/// this directly rather than through the job queue. /// this directly rather than through the job queue.
pub(crate) async fn status(ctx: &Ctx) -> Event { pub(crate) async fn status(ctx: &Ctx) -> Event {
match ctx.db.counts().await { match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await {
Ok((pending, downloaded)) => { Ok((pending, downloaded)) => {
let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0); let feeds = tracing::Instrument::instrument(subscriptions(ctx), db::step("subscriptions"))
.await
.map(|s| s.len())
.unwrap_or(0);
// Kept for /metrics, which asks the database nothing (#195); the daemon counts every 30 s. // Kept for /metrics, which asks the database nothing (#195); the daemon counts every 30 s.
use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::Ordering::Relaxed;
let st = &metrics::STATS; let st = &metrics::STATS;
@@ -596,13 +643,19 @@ async fn daemon(
// A database with nobody in it cannot be signed into. // A database with nobody in it cannot be signed into.
if ctx.db.users().await?.is_empty() { if ctx.db.users().await?.is_empty() {
ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true).await?; let dir = ctx.config_path.parent().unwrap_or(std::path::Path::new("."));
let (password, told) = first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?;
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), true).await?;
tracing::warn!( tracing::warn!(
"no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \ "no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipx user passwd admin`"
Change it with `echo -n <password> | ipx user passwd admin`"
); );
} }
// Unread lists start from here, and a reap may be a day off (#347). Slower without, never wrong.
if let Err(e) = ctx.db.advance_unread_from().await {
tracing::warn!(error = %format!("{e:#}"), "could not work out where unread items start");
}
if let Some(admin) = ctx.db.users().await?.into_iter().find(|u| u.is_admin) { if let Some(admin) = ctx.db.users().await?.into_iter().find(|u| u.is_admin) {
let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect(); let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect();
match ctx.db.adopt_catalogue(admin.id, &catalogue).await { match ctx.db.adopt_catalogue(admin.id, &catalogue).await {
@@ -937,7 +990,7 @@ async fn add_one(
taken.entry(id).or_insert_with(|| probe.clone()); taken.entry(id).or_insert_with(|| probe.clone());
} }
} }
let id = config::unique_slug(&title, &taken); let id = config::unique_slug(&title, |s| taken.contains_key(s));
cfg.feeds.insert(id.clone(), probe); cfg.feeds.insert(id.clone(), probe);
Ok(id) Ok(id)
} }
@@ -949,17 +1002,17 @@ fn url_stem(url: &str) -> String {
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> { async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
let mut cfg = ctx.edit_cfg().await; let mut cfg = ctx.edit_cfg().await;
if cfg.feeds.remove(feed).is_none() { let Some(parent) = cfg.feeds.remove(feed) else {
// Derived from an OPML: drop it here, though the subscription will list it again // Derived from an OPML: drop it here, though the subscription will list it again
// on the next read unless the OPML itself goes. // on the next read unless the OPML itself goes.
ctx.db.drop_managed(feed).await?; ctx.db.drop_managed(feed).await?;
println!("removed {feed}; it came from an OPML subscription and may return on the next read"); println!("removed {feed}; it came from an OPML subscription and may return on the next read");
return Ok(()); return Ok(());
} };
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
// State and files stay: re-adding the feed should not re-download its back catalogue. // State and files stay: re-adding the feed should not re-download its back catalogue.
println!("removed {feed}; downloads and history kept"); println!("removed {feed}; downloads and history kept");
retire_group(ctx, feed).await?; retire_group(ctx, feed, Some(&parent)).await?;
Ok(()) Ok(())
} }
@@ -973,12 +1026,26 @@ async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
.into_iter() .into_iter()
.find(|u| u.is_admin) .find(|u| u.is_admin)
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?; .ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?; let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = subscribe_opml(ctx, &doc, admin.id).await?; let (added, had) = subscribe_opml(ctx, opml_feeds(&doc)?, admin.id).await?;
println!("subscribed {} to {added} feed(s); {had} already there", admin.name); println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
Ok(()) Ok(())
} }
/// The feeds an OPML document lists, up to as many as a subscribed list may bring in: each becomes
/// a feed in the catalogue, scanned for everyone, and one upload of tens of thousands held the
/// catalogue for minutes and stayed in it (#338).
pub fn opml_feeds(doc: &opml::OPML) -> Result<Vec<(String, String)>> {
let mut found = vec![];
feed::collect_outlines(&doc.body.outlines, &mut found);
anyhow::ensure!(
found.len() <= MAX_LISTED,
"it lists {} feeds, more than the {MAX_LISTED} one may have",
found.len()
);
Ok(found)
}
/// Subscribes one person to every feed in an OPML document, for the CLI and the web alike. /// Subscribes one person to every feed in an OPML document, for the CLI and the web alike.
/// A feed already in the catalogue costs nothing; an unknown one is added under the OPML's /// A feed already in the catalogue costs nothing; an unknown one is added under the OPML's
/// title rather than refetching each. Returns (newly subscribed, already subscribed). /// title rather than refetching each. Returns (newly subscribed, already subscribed).
@@ -987,27 +1054,27 @@ async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
/// decided what each person sees, that imported nothing at all for a feed someone else /// decided what each person sees, that imported nothing at all for a feed someone else
/// already had, and a new one had no subscriber, so it was never scanned. /// already had, and a new one had no subscriber, so it was never scanned.
/// ///
/// The caller parses the document, so each refuses a file that is not OPML in its own terms, /// The caller parses the document and lists its feeds (`opml_feeds`), so each refuses a file in
/// before anything is touched: a 400 from the web, a message from the CLI. /// its own terms, before anything is touched: a 400 from the web, a message from the CLI.
pub async fn subscribe_opml(ctx: &Ctx, doc: &opml::OPML, user_id: i64) -> Result<(usize, usize)> { pub async fn subscribe_opml(ctx: &Ctx, found: Vec<(String, String)>, user_id: i64) -> Result<(usize, usize)> {
let mut found = vec![];
feed::collect_outlines(&doc.body.outlines, &mut found);
let mut cfg = ctx.edit_cfg().await; let mut cfg = ctx.edit_cfg().await;
let known = subscriptions(ctx).await?; // By address, once: each feed looked through every subscription and the whole catalogue, and
// the catalogue's lock is held throughout (#338).
let mut known = std::collections::HashMap::new();
for s in subscriptions(ctx).await? {
// The first with an address wins, as a search in order found it.
known.entry(s.cfg.url).or_insert(s.id);
}
let mut ids = vec![]; let mut ids = vec![];
let mut grew = false; let mut grew = false;
for (title, url) in found { for (title, url) in found {
let existing = known // Known already, or the same URL listed twice in one file.
.iter() let existing = known.get(&url).cloned();
.find(|s| s.cfg.url == url)
.map(|s| s.id.clone())
// The same URL listed twice in one file.
.or_else(|| cfg.feeds.iter().find(|(_, f)| f.url == url).map(|(id, _)| id.clone()));
let id = match existing { let id = match existing {
Some(id) => id, Some(id) => id,
None => { None => {
let id = config::unique_slug(&title, &cfg.feeds); let id = config::unique_slug(&title, |s| cfg.feeds.contains_key(s));
known.insert(url.clone(), id.clone());
cfg.feeds.insert( cfg.feeds.insert(
id.clone(), id.clone(),
config::Feed { config::Feed {
@@ -1144,15 +1211,17 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
} }
let mut cfg = ctx.edit_cfg().await; let mut cfg = ctx.edit_cfg().await;
// Every one removed, so not `any`, which would stop at the first. // Every one removed, so not `any`, which would stop at the first.
let mut removed = false; let mut removed = std::collections::HashMap::new();
for id in &stale { for id in &stale {
removed |= cfg.feeds.remove(id).is_some(); if let Some(f) = cfg.feeds.remove(id) {
removed.insert(id.clone(), f);
}
} }
if removed { if !removed.is_empty() {
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
} }
for id in &stale { for id in &stale {
retire_group(ctx, id).await?; retire_group(ctx, id, removed.get(id)).await?;
ctx.db.forget_feed(id).await?; ctx.db.forget_feed(id).await?;
tracing::info!( tracing::info!(
feed = id, feed = id,
@@ -1174,8 +1243,12 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
span.record("entries", r.entries_pruned); span.record("entries", r.entries_pruned);
if !dry_run { if !dry_run {
tracing::Instrument::instrument(clean_directory(ctx), db::step("clean_directory")).await?; tracing::Instrument::instrument(clean_directory(ctx), db::step("clean_directory")).await?;
let gone = let moved =
db::step("trim_artwork").in_scope(|| art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576)); tracing::Instrument::instrument(ctx.db.advance_unread_from(), db::step("advance_unread_from")).await?;
tracing::debug!(moved, "unread lists start from each person's oldest unread item");
// Off the async threads: it walks every file kept, on FUSE (#345).
let (span, limit) = (db::step("trim_artwork"), ctx.cfg().general.art_cache_mb * 1_048_576);
let gone = tokio::task::spawn_blocking(move || span.in_scope(|| art::trim(&art::dir(), limit))).await?;
if gone > 0 { if gone > 0 {
tracing::debug!(gone, "trimmed the artwork kept on disk"); tracing::debug!(gone, "trimmed the artwork kept on disk");
} }
@@ -1384,6 +1457,7 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
} }
}; };
let title = m.title.clone().unwrap_or_else(|| m.id.clone()); let title = m.title.clone().unwrap_or_else(|| m.id.clone());
let (username, password, password_env) = parent.map(|p| p.credentials_for(&m.url)).unwrap_or_default();
out.push(Sub { out.push(Sub {
id: m.id.clone(), id: m.id.clone(),
cfg: config::Feed { cfg: config::Feed {
@@ -1396,9 +1470,9 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
allow_explicit: parent.is_some_and(|p| p.allow_explicit), allow_explicit: parent.is_some_and(|p| p.allow_explicit),
auto_download: parent.is_none_or(|p| p.auto_download), auto_download: parent.is_none_or(|p| p.auto_download),
max_new_per_check: parent.and_then(|p| p.max_new_per_check), max_new_per_check: parent.and_then(|p| p.max_new_per_check),
username: parent.and_then(|p| p.username.clone()), username,
password: parent.and_then(|p| p.password.clone()), password,
password_env: parent.and_then(|p| p.password_env.clone()), password_env,
category: None, category: None,
listed: false, listed: false,
private: false, private: false,
@@ -1413,15 +1487,19 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
/// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's /// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's
/// lists are left alone. /// lists are left alone.
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> { pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
ctx.db.unsubscribe(user_id, id).await?; use tracing::Instrument;
ctx.db.unsubscribe(user_id, id).instrument(db::step("unsubscribe")).await?;
// What they kept stays on their list, a feed of its own once nobody reads the list (#280). // What they kept stays on their list, a feed of its own once nobody reads the list (#280).
let kept = ctx.db.kept_feeds(user_id).await?; let kept = ctx.db.kept_feeds(user_id).instrument(db::step("kept")).await?;
for child in for child in subscriptions(ctx)
subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id) && !kept.contains(&s.id)) .instrument(db::step("subscriptions"))
.await?
.iter()
.filter(|s| s.cfg.group.as_deref() == Some(id) && !kept.contains(&s.id))
{ {
ctx.db.unsubscribe(user_id, &child.id).await?; ctx.db.unsubscribe(user_id, &child.id).instrument(db::step("unsubscribe")).await?;
} }
if ctx.db.subscriber_counts().await?.contains_key(id) { if ctx.db.subscriber_counts().instrument(db::step("subscriber_counts")).await?.contains_key(id) {
return Ok(()); return Ok(());
} }
@@ -1432,13 +1510,13 @@ pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
if cfg.feeds.get(id).is_some_and(|f| f.listed) { if cfg.feeds.get(id).is_some_and(|f| f.listed) {
return Ok(()); return Ok(());
} }
if cfg.feeds.remove(id).is_none() { let Some(parent) = cfg.feeds.remove(id) else {
// A derived feed: forget it here, though the OPML will list it again on the next // A derived feed: forget it here, though the OPML will list it again on the next
// read unless you unsubscribe from the OPML itself. // read unless you unsubscribe from the OPML itself.
return ctx.db.drop_managed(id).await; return ctx.db.drop_managed(id).await;
} };
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
retire_group(ctx, id).await retire_group(ctx, id, Some(&parent)).await
} }
/// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys /// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys
@@ -1463,15 +1541,16 @@ pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> {
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent /// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
/// itself is removed, since `subscriptions()` would otherwise keep scanning them under a /// itself is removed, since `subscriptions()` would otherwise keep scanning them under a
/// fallback policy meant for a feed with no parent at all. A feed promoted to config is not /// fallback policy meant for a feed with no parent at all. A feed promoted to config is not
/// derived any more, so it is only unmanaged. /// derived any more, so it is only unmanaged. `parent` is the list's settings as they were before
pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> { /// it was removed, None when that is no longer known.
pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Feed>) -> Result<()> {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
for m in ctx.db.managed_feeds().await?.into_iter().filter(|m| m.group_id == parent_id) { for m in ctx.db.managed_feeds().await?.into_iter().filter(|m| m.group_id == parent_id) {
if cfg.feeds.contains_key(&m.id) { if cfg.feeds.contains_key(&m.id) {
// Scanned from its config entry and still read. Dropped as derived, its stored // Scanned from its config entry and still read. Dropped as derived, its stored
// entries would go with it: davewiner's 11 were promoted without being unmanaged. // entries would go with it: davewiner's 11 were promoted without being unmanaged.
ctx.db.unmanage(&m.id).await?; ctx.db.unmanage(&m.id).await?;
} else if promote_kept(ctx, &m).await? { } else if promote_kept(ctx, &m, parent).await? {
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now"); tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 { } else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
ctx.db.set_orphaned(&m.id, true).await?; ctx.db.set_orphaned(&m.id, true).await?;
@@ -1485,31 +1564,34 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
/// Makes a feed from an OPML or Patreon list a feed of its own, for those who kept it (#280): a /// Makes a feed from an OPML or Patreon list a feed of its own, for those who kept it (#280): a
/// catalogue entry in no group, which the list no longer adds or takes away. Whoever had it only /// catalogue entry in no group, which the list no longer adds or takes away. Whoever had it only
/// through the list, without keeping it, loses it, as they would have when the list dropped it. /// through the list, without keeping it, loses it, as they would have when the list dropped it.
/// False, changing nothing, when nobody kept it. /// False, changing nothing, when nobody kept it. `base` is the list's settings: most callers have
async fn promote_kept(ctx: &Ctx, m: &db::Managed) -> Result<bool> { /// removed the list from config by now, so they pass them.
async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&config::Feed>) -> Result<bool> {
let subs = ctx.db.subscribers_of(&m.id).await?; let subs = ctx.db.subscribers_of(&m.id).await?;
if !subs.iter().any(|(_, kept)| *kept) { if !subs.iter().any(|(_, kept)| *kept) {
return Ok(false); return Ok(false);
} }
let mut cfg = ctx.edit_cfg().await; let mut cfg = ctx.edit_cfg().await;
// The list's own settings carry over while it is there; its folder and group do not. // The list's own settings carry over; its folder and group do not.
let base = cfg.feeds.get(&m.group_id).cloned(); let (username, password, password_env) = base.map(|p| p.credentials_for(&m.url)).unwrap_or_default();
cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed { cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
url: m.url.clone(), url: m.url.clone(),
folder: None, folder: None,
group: None, group: None,
media_types: base.as_ref().and_then(|p| p.media_types.clone()), media_types: base.and_then(|p| p.media_types.clone()),
schedule: base.as_ref().and_then(|p| p.schedule.clone()), schedule: base.and_then(|p| p.schedule.clone()),
keywords: base.as_ref().map(|p| p.keywords.clone()).unwrap_or_default(), keywords: base.map(|p| p.keywords.clone()).unwrap_or_default(),
allow_explicit: base.as_ref().is_some_and(|p| p.allow_explicit), allow_explicit: base.is_some_and(|p| p.allow_explicit),
auto_download: base.as_ref().is_none_or(|p| p.auto_download), auto_download: base.is_none_or(|p| p.auto_download),
max_new_per_check: base.as_ref().and_then(|p| p.max_new_per_check), max_new_per_check: base.and_then(|p| p.max_new_per_check),
username: base.as_ref().and_then(|p| p.username.clone()), username,
password: base.as_ref().and_then(|p| p.password.clone()), password,
password_env: base.as_ref().and_then(|p| p.password_env.clone()), password_env,
category: None, category: None,
listed: false, listed: false,
private: base.as_ref().is_some_and(|p| p.private), // Looked up after the list was removed, private was always false, and a private OPML's
// kept show went to the directory (#332). A list no longer known may have been private.
private: base.is_none_or(|p| p.private),
}); });
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
ctx.db.unmanage(&m.id).await?; ctx.db.unmanage(&m.id).await?;
@@ -1532,7 +1614,7 @@ async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
let stranded: std::collections::BTreeSet<&str> = let stranded: std::collections::BTreeSet<&str> =
before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect(); before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect();
for group in stranded { for group in stranded {
retire_group(ctx, group).await?; retire_group(ctx, group, None).await?;
} }
Ok(before.len() - ctx.db.managed_feeds().await?.len()) Ok(before.len() - ctx.db.managed_feeds().await?.len())
} }
@@ -1704,7 +1786,7 @@ async fn scan_one(
// heap. The items go; its files and read state move to each show as the show // heap. The items go; its files and read state move to each show as the show
// lists them (`Db::adopt`), so no show comes up empty for want of a URL. // lists them (`Db::adopt`), so no show comes up empty for want of a URL.
ctx.db.clear_entries(id).await?; ctx.db.clear_entries(id).await?;
return sync_group(ctx, id, feed_cfg, &shows).await; return sync_group(ctx, id, &shows).await;
} }
Ok(_) => {} // One show: the creator's feed is that show. Ok(_) => {} // One show: the creator's feed is that show.
// Already split: keep the shows it has rather than read the creator as one heap. // Already split: keep the shows it has rather than read the creator as one heap.
@@ -1782,7 +1864,7 @@ async fn scan_one(
// URL; sniffing the body also catches one served from a URL without that extension. // URL; sniffing the body also catches one served from a URL without that extension.
if feed::is_opml(&bytes) { if feed::is_opml(&bytes) {
ctx.db.touch_feed(id, &feed_cfg.url).await?; ctx.db.touch_feed(id, &feed_cfg.url).await?;
return sync_opml(ctx, id, feed_cfg, &bytes).await; return sync_opml(ctx, id, &bytes).await;
} }
let mut parsed = feed::parse(&bytes)?; let mut parsed = feed::parse(&bytes)?;
@@ -2017,14 +2099,17 @@ async fn scan_one(
/// A feed's body fetched by the scan ahead of its turn, and the site's icon if one was looked up. /// A feed's body fetched by the scan ahead of its turn, and the site's icon if one was looked up.
type Prefetched = (Result<(feed::Fetched, Option<String>)>, Option<String>); type Prefetched = (Result<(feed::Fetched, Option<String>)>, Option<String>);
async fn sync_opml(ctx: &Arc<Ctx>, parent_id: &str, parent: &config::Feed, bytes: &[u8]) -> Result<Outcome> { async fn sync_opml(ctx: &Arc<Ctx>, parent_id: &str, bytes: &[u8]) -> Result<Outcome> {
let listed = feed::parse_opml(bytes)?; let listed = feed::parse_opml(bytes)?;
if let Some(title) = feed::opml_title(bytes) { if let Some(title) = feed::opml_title(bytes) {
ctx.db.set_title(parent_id, &title).await?; ctx.db.set_title(parent_id, &title).await?;
} }
sync_group(ctx, parent_id, parent, &listed).await sync_group(ctx, parent_id, &listed).await
} }
/// How many feeds an OPML or Patreon list may bring in.
const MAX_LISTED: usize = 2000;
/// Brings the feed list in step with a list of feeds: a subscribed OPML, or a Patreon /// Brings the feed list in step with a list of feeds: a subscribed OPML, or a Patreon
/// creator's shows. /// creator's shows.
/// ///
@@ -2032,15 +2117,22 @@ async fn sync_opml(ctx: &Arc<Ctx>, parent_id: &str, parent: &config::Feed, bytes
/// the list is unsubscribed *only if nothing was ever downloaded for it* -- otherwise it /// the list is unsubscribed *only if nothing was ever downloaded for it* -- otherwise it
/// is kept and flagged, because dropping it would orphan files on disk with nothing in /// is kept and flagged, because dropping it would orphan files on disk with nothing in
/// the UI to explain them. /// the UI to explain them.
async fn sync_group( async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(String, String)]) -> Result<Outcome> {
ctx: &Arc<Ctx>, // Each is a feed scanned for everyone subscribed to the list, and the list is its author's to
parent_id: &str, // write: one of tens of thousands held the scan for hours (#337). davewiner's had 922.
parent: &config::Feed, anyhow::ensure!(
listed: &[(String, String)], listed.len() <= MAX_LISTED,
) -> Result<Outcome> { "it lists {} feeds, more than the {MAX_LISTED} a list may have",
listed.len()
);
let cfg = ctx.cfg(); let cfg = ctx.cfg();
let existing = ctx.db.managed_feeds().await?; let existing = ctx.db.managed_feeds().await?;
let mut added = vec![]; let mut added = vec![];
// Read once, not once a new feed: each read is the whole feeds table.
let known = ctx.db.feed_urls().await?;
let fixed: std::collections::HashSet<&str> =
cfg.feeds.keys().chain(existing.iter().map(|m| &m.id)).map(String::as_str).collect();
let known_ids: std::collections::HashSet<&str> = known.iter().map(|(id, _)| id.as_str()).collect();
for (title, url) in listed { for (title, url) in listed {
// Already known, whether derived or promoted into the config. // Already known, whether derived or promoted into the config.
@@ -2053,16 +2145,11 @@ async fn sync_group(
continue; continue;
} }
// A removed feed keeps its rows, so its id is only free again for the same feed. // A removed feed keeps its rows, so its id is only free again for the same feed.
let known = ctx.db.feed_urls().await?; let its_own: Vec<&str> =
let taken: std::collections::BTreeMap<String, config::Feed> = cfg known.iter().filter(|(_, u)| feed::same_feed(u, url)).map(|(id, _)| id.as_str()).collect();
.feeds let id = config::unique_slug(title, |s| {
.keys() fixed.contains(s) || added.iter().any(|a| a == s) || (known_ids.contains(s) && !its_own.contains(&s))
.chain(existing.iter().map(|m| &m.id)) });
.chain(added.iter())
.chain(known.iter().filter(|(_, u)| !feed::same_feed(u, url)).map(|(id, _)| id))
.map(|id| (id.clone(), parent.clone()))
.collect();
let id = config::unique_slug(title, &taken);
ctx.db.upsert_managed(&id, url, title, parent_id).await?; ctx.db.upsert_managed(&id, url, title, parent_id).await?;
added.push(id); added.push(id);
} }
@@ -2088,11 +2175,12 @@ async fn sync_group(
// Anything in this group the OPML no longer lists. // Anything in this group the OPML no longer lists.
let mut removed = 0; let mut removed = 0;
let mut kept = 0; let mut kept = 0;
let parent = ctx.cfg().feeds.get(parent_id).cloned();
for m in existing.iter().filter(|m| m.group_id == parent_id) { for m in existing.iter().filter(|m| m.group_id == parent_id) {
if listed.iter().any(|(_, u)| u == &m.url) { if listed.iter().any(|(_, u)| u == &m.url) {
continue; continue;
} }
if promote_kept(ctx, m).await? { if promote_kept(ctx, m, parent.as_ref()).await? {
kept += 1; kept += 1;
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now"); tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
continue; continue;
@@ -2393,7 +2481,7 @@ async fn torrent_one(
let cfg = ctx.cfg(); let cfg = ctx.cfg();
ctx.torrents() ctx.torrents()
.await? .await?
.fetch(&cfg, url, dest_dir, |done, total| { .fetch(&cfg, &ctx.client, url, &torrent::own_folder(dest_dir, enclosure), |done, total| {
if let Some(pct) = (done * 100).checked_div(total).map(|p| p as i64) if let Some(pct) = (done * 100).checked_div(total).map(|p| p as i64)
&& pct > last_pct && pct > last_pct
{ {
@@ -2429,6 +2517,30 @@ fn duration(secs: u64) -> String {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn the_first_admin_gets_a_password_nobody_else_knows() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("ipx-first-password-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (a, told) = first_password(&dir, None).unwrap();
let file = dir.join("admin-password");
assert_eq!(std::fs::read_to_string(&file).unwrap(), a);
assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
assert!(told.contains("admin-password") && !told.contains(&a), "the log says where, not what: {told}");
assert_eq!(a.len(), 24);
assert_ne!(a, "ipodderx");
assert_ne!(first_password(&dir, None).unwrap().0, a, "each new database gets its own");
std::fs::remove_dir_all(&dir).unwrap();
let (b, told) = first_password(&dir, Some("chosen".into())).unwrap();
assert_eq!(b, "chosen");
assert!(!told.contains("chosen"));
assert!(!file.exists(), "a password set in the environment is not written anywhere");
assert_eq!(first_password(&dir, Some(String::new())).unwrap().0.len(), 24, "empty means unset");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test] #[test]
fn a_feed_is_due_after_its_wait_and_daily_with_nobody_subscribed() { fn a_feed_is_due_after_its_wait_and_daily_with_nobody_subscribed() {
let cfg = config::Config::default(); let cfg = config::Config::default();
@@ -2621,6 +2733,7 @@ mod tests {
cfg: std::sync::RwLock::new(Arc::new(cfg)), cfg: std::sync::RwLock::new(Arc::new(cfg)),
db: db::Db::memory().await.unwrap(), db: db::Db::memory().await.unwrap(),
client: reqwest::Client::new(), client: reqwest::Client::new(),
local_client: reqwest::Client::new(),
feed_client: reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap(), feed_client: reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap(),
out: Emitter::terminal(), out: Emitter::terminal(),
torrents: tokio::sync::OnceCell::new(), torrents: tokio::sync::OnceCell::new(),
@@ -2993,7 +3106,7 @@ mod tests {
ctx.db.record_enclosure("has-file", "g1", &enc).await.unwrap(); ctx.db.record_enclosure("has-file", "g1", &enc).await.unwrap();
ctx.db.mark_downloaded(&enc.url, std::path::Path::new("/downloads/ep.mp3"), 1).await.unwrap(); ctx.db.mark_downloaded(&enc.url, std::path::Path::new("/downloads/ep.mp3"), 1).await.unwrap();
retire_group(&ctx, "parent").await.unwrap(); retire_group(&ctx, "parent", None).await.unwrap();
let managed = ctx.db.managed_feeds().await.unwrap(); let managed = ctx.db.managed_feeds().await.unwrap();
assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten"); assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten");
@@ -3001,10 +3114,42 @@ mod tests {
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned"); assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
} }
#[tokio::test]
async fn an_opml_upload_is_capped_and_takes_a_repeated_address_once() {
let opml = |n: usize| {
let outlines: String =
(0..n).map(|i| format!(r#"<outline text="Same" xmlUrl="http://x/{}.xml"/>"#, i % 3)).collect();
feed::read_opml(&format!(r#"<opml version="2.0"><head/><body>{outlines}</body></opml>"#)).unwrap()
};
assert!(opml_feeds(&opml(MAX_LISTED + 1)).unwrap_err().to_string().contains("more than the 2000"));
let ctx = test_ctx(config::Config::default()).await;
let me = ctx.db.create_user("me", None, false).await.unwrap();
// Six outlines, three addresses, one title: three feeds, named apart.
let (added, had) = subscribe_opml(&ctx, opml_feeds(&opml(6)).unwrap(), me).await.unwrap();
let ids: std::collections::BTreeSet<String> = ctx.cfg().feeds.keys().cloned().collect();
assert_eq!(ids, ["same", "same-2", "same-3"].map(String::from).into());
assert_eq!((added, had), (3, 3));
}
#[tokio::test]
async fn a_list_of_more_feeds_than_the_limit_is_refused() {
let ctx = Arc::new(test_ctx(config::Config::default()).await);
let listed: Vec<(String, String)> =
(0..=MAX_LISTED).map(|n| (format!("Show {n}"), format!("http://x/{n}.xml"))).collect();
let err = sync_group(&ctx, "opml", &listed).await.err().expect("refused");
assert!(err.to_string().contains("more than the 2000"), "{err:#}");
assert!(ctx.db.managed_feeds().await.unwrap().is_empty(), "nothing taken from it");
// At the limit, all of them, each its own id.
sync_group(&ctx, "opml", &listed[..MAX_LISTED]).await.unwrap();
let ids: std::collections::HashSet<String> =
ctx.db.managed_feeds().await.unwrap().into_iter().map(|m| m.id).collect();
assert_eq!(ids.len(), MAX_LISTED);
}
#[tokio::test] #[tokio::test]
async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() { async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() {
let mut cfg = config::Config::default(); let mut cfg = config::Config::default();
cfg.feeds.insert("opml".into(), feed()); cfg.feeds.insert("opml".into(), config::Feed { private: true, ..feed() });
let ctx = Arc::new(test_ctx(cfg).await); let ctx = Arc::new(test_ctx(cfg).await);
let (me, you) = ( let (me, you) = (
ctx.db.create_user("me", None, false).await.unwrap(), ctx.db.create_user("me", None, false).await.unwrap(),
@@ -3022,10 +3167,9 @@ mod tests {
assert!(ctx.db.set_kept(me, "b", true).await.unwrap()); assert!(ctx.db.set_kept(me, "b", true).await.unwrap());
// The OPML drops a: mine to keep, a feed of its own; yours, unkept, goes. // The OPML drops a: mine to keep, a feed of its own; yours, unkept, goes.
let parent = ctx.cfg().feeds["opml"].clone(); sync_group(&ctx, "opml", &[("b".into(), "http://x/b.xml".into())]).await.unwrap();
sync_group(&ctx, "opml", &parent, &[("b".into(), "http://x/b.xml".into())]).await.unwrap();
let a = ctx.cfg().feeds.get("a").cloned().expect("a is in the catalogue"); let a = ctx.cfg().feeds.get("a").cloned().expect("a is in the catalogue");
assert_eq!((a.url.as_str(), a.group), ("http://x/a.xml", None)); assert_eq!((a.url.as_str(), a.group, a.private), ("http://x/a.xml", None, true));
assert!(!ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "a")); assert!(!ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "a"));
assert!(ctx.db.subscription(me, "a").await.unwrap().is_some()); assert!(ctx.db.subscription(me, "a").await.unwrap().is_some());
assert!(ctx.db.subscription(you, "a").await.unwrap().is_none()); assert!(ctx.db.subscription(you, "a").await.unwrap().is_none());
@@ -3036,7 +3180,8 @@ mod tests {
assert!(ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "b")); assert!(ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "b"));
// Once nobody reads it, b is mine alone, and yours goes with the OPML. // Once nobody reads it, b is mine alone, and yours goes with the OPML.
leave(&ctx, you, "opml").await.unwrap(); leave(&ctx, you, "opml").await.unwrap();
assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none())); // Still private: the OPML is gone from config by the time b is promoted (#332).
assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none() && f.private));
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some()); assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
assert!(ctx.db.subscription(you, "b").await.unwrap().is_none()); assert!(ctx.db.subscription(you, "b").await.unwrap().is_none());
} }

View File

@@ -94,7 +94,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
hits = tracing::field::Empty hits = tracing::field::Empty
); );
async { async {
let groups = calls(&items, server.is_some()); let groups = calls(&items);
let paid = if server.is_some() { "server" } else { "own" }; let paid = if server.is_some() { "server" } else { "own" };
tracing::Span::current().record("calls", groups.len()); tracing::Span::current().record("calls", groups.len());
let refused = std::sync::Mutex::new(HashSet::new()); let refused = std::sync::Mutex::new(HashSet::new());
@@ -184,23 +184,11 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
.await .await
} }
/// The calls a batch makes, each one item and the people asking about it. On the server's key /// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
/// everyone's Signals on an item go in one call, its text sent once (#275); on people's own keys /// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
/// each pays for their own. `unjudged` puts one item's rows together. /// then one person's description was in the question that decided another's verdicts (#343).
fn calls(items: &[Unjudged], share: bool) -> Vec<Vec<&Unjudged>> { fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
let mut out: Vec<Vec<&Unjudged>> = Vec::new(); items.iter().map(|item| vec![item]).collect()
for item in items {
match out.last_mut() {
Some(g)
if share
&& (g[0].feed_id.as_str(), g[0].guid.as_str()) == (item.feed_id.as_str(), item.guid.as_str()) =>
{
g.push(item)
}
_ => out.push(vec![item]),
}
}
out
} }
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status /// A call TypeSafe answered with an error, and what it said: its body says why, where the status
@@ -590,7 +578,7 @@ mod tests {
} }
#[test] #[test]
fn on_the_servers_key_one_item_is_one_call_for_everyone() { fn one_persons_signals_never_share_a_call_with_anothers() {
let row = |user_id, guid: &str| Unjudged { let row = |user_id, guid: &str| Unjudged {
user_id, user_id,
user_name: format!("u{user_id}"), user_name: format!("u{user_id}"),
@@ -603,14 +591,11 @@ mod tests {
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], 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 items = [row(1, "x"), row(2, "x"), row(1, "y")];
let shape = |share| { let shape: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
calls(&items, share).iter().map(|g| g.iter().map(|i| i.user_id).collect::<Vec<_>>()).collect::<Vec<_>>() 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();
assert_eq!(shape(true), [vec![1, 2], vec![1]], "x asked once, for both"); let r = request(&items[0], &mine);
assert_eq!(shape(false), [vec![1], vec![2], vec![1]], "each on their own key"); assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
let both: Vec<&Ask> = calls(&items, true)[0].iter().flat_map(|i| &i.signals).collect();
let r = request(&items[0], &both);
assert!(r["questions"]["signal_10"].is_object() && r["questions"]["signal_20"].is_object());
} }
#[test] #[test]

View File

@@ -39,13 +39,15 @@ impl Torrents {
} }
/// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`, /// 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 /// Downloading in place rather than into a staging dir is deliberate: seeding serves
/// the files it downloaded, so moving them first would break it. /// the files it downloaded, so moving them first would break it.
pub async fn fetch( pub async fn fetch(
&self, &self,
cfg: &Config, cfg: &Config,
client: &reqwest::Client,
url: &str, url: &str,
dest_dir: &Path, dest_dir: &Path,
mut on_progress: impl FnMut(u64, u64), mut on_progress: impl FnMut(u64, u64),
@@ -53,6 +55,15 @@ impl Torrents {
tokio::fs::create_dir_all(dest_dir).await?; tokio::fs::create_dir_all(dest_dir).await?;
let limit = stall_limit(cfg); 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 // 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 // swarm it never returns. The stall budget has to cover this phase too, or a
@@ -60,7 +71,7 @@ impl Torrents {
let added = tokio::time::timeout( let added = tokio::time::timeout(
limit, limit,
self.session.add_torrent( self.session.add_torrent(
AddTorrent::from_url(url), source,
Some(AddTorrentOptions { Some(AddTorrentOptions {
output_folder: Some(dest_dir.to_string_lossy().into_owned()), output_folder: Some(dest_dir.to_string_lossy().into_owned()),
overwrite: true, overwrite: true,
@@ -103,8 +114,10 @@ impl Torrents {
} }
} }
let name = handle.name().unwrap_or_else(|| "torrent".to_owned()); // The torrent's own name is the publisher's to choose, and librqbit checks it only when it
let path = handle.output_folder().join(&name); // 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; let size = handle.stats().total_bytes;
self.seed(cfg, &handle).await; self.seed(cfg, &handle).await;
@@ -134,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. /// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero.
pub fn ratio(uploaded: u64, total: u64) -> f64 { pub fn ratio(uploaded: u64, total: u64) -> f64 {
if total == 0 { if total == 0 {
@@ -154,6 +191,32 @@ mod tests {
assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever"); 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] #[test]
fn stall_limit_is_never_zero() { fn stall_limit_is_never_zero() {
let mut cfg = Config::default(); let mut cfg = Config::default();

View File

@@ -89,6 +89,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/api/login-check", get(login_check)) .route("/api/login-check", get(login_check))
.route_layer(middleware::from_fn(name_span)) .route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(cross_site_refused))
.layer(middleware::from_fn(access_log)) .layer(middleware::from_fn(access_log))
.with_state(state) .with_state(state)
} }
@@ -497,6 +498,30 @@ struct IconAsk {
candidates: Vec<(String, String)>, candidates: Vec<(String, String)>,
} }
/// How many icons one person may have Jev pick in an hour. Each is a call on the server's key, and
/// the route is not tied to a Signal, so a loop on it ran up the server's bill and held back
/// everyone's Signals behind the shared pace (#340). A Signal is named a few times at most.
const ICON_PICKS_AN_HOUR: u32 = 20;
static ICON_PICKS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<i64, (std::time::Instant, u32)>>> =
std::sync::LazyLock::new(Default::default);
/// Counts a pick against the person's hour, which starts at their first pick in it.
fn icon_pick_allowed(
picks: &mut std::collections::HashMap<i64, (std::time::Instant, u32)>,
user: i64,
now: std::time::Instant,
) -> bool {
let (start, used) = picks.entry(user).or_insert((now, 0));
if now.duration_since(*start) >= std::time::Duration::from_secs(3600) {
(*start, *used) = (now, 0);
}
if *used >= ICON_PICKS_AN_HOUR {
return false;
}
*used += 1;
true
}
/// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`, /// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`,
/// or 404 when the server has no key. /// or 404 when the server has no key.
async fn pick_signal_icon( async fn pick_signal_icon(
@@ -507,6 +532,9 @@ async fn pick_signal_icon(
let Some(key) = crate::signals::typesafe_key() else { let Some(key) = crate::signals::typesafe_key() else {
return Err(ApiError::not_found("picking an icon is not set up on this server")); return Err(ApiError::not_found("picking an icon is not set up on this server"));
}; };
if !icon_pick_allowed(&mut ICON_PICKS.lock().unwrap(), user.id, std::time::Instant::now()) {
return Err(ApiError::too_many("enough icons picked for one hour; choose one by hand"));
}
let name = signal_name(&body.name)?; let name = signal_name(&body.name)?;
let words = clean_words(body.keywords)?; let words = clean_words(body.keywords)?;
let description = signal_description(body.description)?; let description = signal_description(body.description)?;
@@ -1249,6 +1277,10 @@ impl ApiError {
fn bad_gateway(msg: impl Into<String>) -> Self { fn bad_gateway(msg: impl Into<String>) -> Self {
Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::BAD_GATEWAY } Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::BAD_GATEWAY }
} }
fn too_many(msg: impl Into<String>) -> Self {
Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::TOO_MANY_REQUESTS }
}
} }
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
@@ -1264,6 +1296,48 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn icon_picks_are_counted_by_the_hour_and_the_person() {
let mut picks = std::collections::HashMap::new();
let t = std::time::Instant::now();
for _ in 0..ICON_PICKS_AN_HOUR {
assert!(icon_pick_allowed(&mut picks, 1, t));
}
assert!(!icon_pick_allowed(&mut picks, 1, t), "one too many (#340)");
assert!(icon_pick_allowed(&mut picks, 2, t), "someone else has their own hour");
assert!(icon_pick_allowed(&mut picks, 1, t + std::time::Duration::from_secs(3600)), "the next hour");
}
#[test]
fn a_change_sent_from_another_site_is_known() {
let h = |pairs: &[(&str, &str)]| {
let mut m = HeaderMap::new();
for (k, v) in pairs {
m.insert(axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(), v.parse().unwrap());
}
m
};
assert!(!from_elsewhere(&h(&[("sec-fetch-site", "same-origin")])));
assert!(!from_elsewhere(&h(&[("sec-fetch-site", "none")])), "typed in the address bar");
assert!(from_elsewhere(&h(&[("sec-fetch-site", "same-site")])), "a sibling subdomain");
assert!(from_elsewhere(&h(&[("sec-fetch-site", "cross-site")])));
let host = ("host", "ipodderx.sdf1.net");
assert!(!from_elsewhere(&h(&[host, ("origin", "https://ipodderx.sdf1.net")])));
assert!(from_elsewhere(&h(&[host, ("origin", "https://evil.sdf1.net")])));
assert!(from_elsewhere(&h(&[host, ("origin", "null")])));
assert!(!from_elsewhere(&h(&[host])), "a script with a token sends neither");
}
#[test]
fn only_inert_downloads_open_in_the_browser() {
for t in ["audio/mpeg", "video/mp4", "image/jpeg", "application/pdf", "text/plain; charset=utf-8"] {
assert!(opens_inline(t), "{t}");
}
for t in ["image/svg+xml", "text/html", "application/xhtml+xml", "text/xml", "application/octet-stream", ""] {
assert!(!opens_inline(t), "{t} could run script as iPX");
}
}
#[test] #[test]
fn a_relative_image_resolves_against_the_post() { fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer(); let mut b = feed_sanitizer();
@@ -1522,12 +1596,23 @@ async fn entry_page(
limit, limit,
search = search.is_some(), search = search.is_some(),
); );
let mut rows = match signal { let list = async {
Some(id) => { match signal {
db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(in_list).await? Some(id) => {
db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(in_list).await
}
None => db.entries_of(user_id, feeds, filter, search, page.offset, limit, &order).instrument(in_list).await,
} }
None => db.entries_of(user_id, feeds, filter, search, page.offset, limit, &order).instrument(in_list).await?,
}; };
let count = async {
match signal {
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await,
None => db.count_of(user_id, feeds, filter, search).instrument(step("count_in")).await,
}
};
// The count beside the list, not after it: each waited on the other, about 40 and 24 ms on the
// busiest route (#346).
let (mut rows, total) = tokio::try_join!(list, count)?;
step("sanitize").in_scope(|| { step("sanitize").in_scope(|| {
let mut sanitizer = feed_sanitizer(); let mut sanitizer = feed_sanitizer();
for row in &mut rows { for row in &mut rows {
@@ -1535,10 +1620,6 @@ async fn entry_page(
} }
}); });
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?; crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
let total = match signal {
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await?,
None => db.count_of(user_id, feeds, filter, search).instrument(step("count_in")).await?,
};
Ok(Json(EntryPage { total, entries: rows })) Ok(Json(EntryPage { total, entries: rows }))
} }
@@ -1717,22 +1798,22 @@ async fn patch_feed(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to. // Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
if let Some(on) = body.pinned if let Some(on) = body.pinned
&& !state.ctx.db.set_pinned(user.id, &id, on).await? && !state.ctx.db.set_pinned(user.id, &id, on).instrument(step("set_pinned")).await?
{ {
return Err(ApiError::not_found("you do not subscribe to that feed")); return Err(ApiError::not_found("you do not subscribe to that feed"));
} }
if let Some(on) = body.kept if let Some(on) = body.kept
&& !state.ctx.db.set_kept(user.id, &id, on).await? && !state.ctx.db.set_kept(user.id, &id, on).instrument(step("set_kept")).await?
{ {
return Err(ApiError::not_found("you do not subscribe to that feed")); return Err(ApiError::not_found("you do not subscribe to that feed"));
} }
if let Some(on) = body.hide_from_all if let Some(on) = body.hide_from_all
&& !state.ctx.db.set_hide_from_all(user.id, &id, on).await? && !state.ctx.db.set_hide_from_all(user.id, &id, on).instrument(step("set_hide_from_all")).await?
{ {
return Err(ApiError::not_found("you do not subscribe to that feed")); return Err(ApiError::not_found("you do not subscribe to that feed"));
} }
if let Some(on) = body.hide_from_signals if let Some(on) = body.hide_from_signals
&& !state.ctx.db.set_hide_from_signals(user.id, &id, on).await? && !state.ctx.db.set_hide_from_signals(user.id, &id, on).instrument(step("set_hide_from_signals")).await?
{ {
return Err(ApiError::not_found("you do not subscribe to that feed")); return Err(ApiError::not_found("you do not subscribe to that feed"));
} }
@@ -1742,15 +1823,15 @@ async fn patch_feed(
return Err(ApiError::bad_request("DeadAir's pause is 200, 300, 400 or 500 ms")); return Err(ApiError::bad_request("DeadAir's pause is 200, 300, 400 or 500 ms"));
} }
if let Some(wanted) = body.deadair { if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).await?; state.ctx.db.set_deadair(user.id, &id, wanted).instrument(step("set_deadair")).await?;
state.ctx.deadair.notify_one(); state.ctx.deadair.notify_one();
} }
if let Some(p) = body.deadair_pause { if let Some(p) = body.deadair_pause {
state.ctx.db.set_deadair_pause(user.id, &id, p).await?; state.ctx.db.set_deadair_pause(user.id, &id, p).instrument(step("set_deadair_pause")).await?;
} }
// What one person wants -- which items, whether to fetch them, how many at a time -- // What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change. // is theirs. It goes on their subscription and nobody else sees the change.
if let Some(mut mine) = state.ctx.db.subscription(user.id, &id).await? { if let Some(mut mine) = state.ctx.db.subscription(user.id, &id).instrument(step("subscription")).await? {
let mut touched = false; let mut touched = false;
if let Some(v) = body.keywords.clone() { if let Some(v) = body.keywords.clone() {
mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect()); mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect());
@@ -1769,10 +1850,10 @@ async fn patch_feed(
touched = true; touched = true;
} }
if touched { if touched {
state.ctx.db.set_subscription(user.id, &mine).await?; state.ctx.db.set_subscription(user.id, &mine).instrument(step("set_subscription")).await?;
} }
if let Some(v) = body.blocked.clone() { if let Some(v) = body.blocked.clone() {
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?; state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).instrument(step("set_blocklist")).await?;
} }
} }
@@ -1791,12 +1872,17 @@ async fn patch_feed(
"the feed's address, folder, schedule and category, and whether it may be shared, are the same for everyone, so only an admin changes them", "the feed's address, folder, schedule and category, and whether it may be shared, are the same for everyone, so only an admin changes them",
)); ));
} }
// Marking a feed private is for those who read it: anyone could keep anyone's feed, or a
// whole OPML's, out of the directory, and only an admin could undo it (#333).
if !user.is_admin && state.ctx.db.subscription(user.id, &id).instrument(step("subscription")).await?.is_none() {
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
let mut cfg = state.ctx.edit_cfg().await; let mut cfg = state.ctx.edit_cfg().await;
// Derived feeds have no config entry. Editing one is the moment it earns a real // Derived feeds have no config entry. Editing one is the moment it earns a real
// entry: promote it, so the config holds your decisions and nothing else. // entry: promote it, so the config holds your decisions and nothing else.
if !cfg.feeds.contains_key(&id) { if !cfg.feeds.contains_key(&id) {
let subs = crate::subscriptions(&state.ctx).await?; let subs = crate::subscriptions(&state.ctx).instrument(step("subscriptions")).await?;
let found = let found =
subs.iter().find(|s| s.id == id).ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?; subs.iter().find(|s| s.id == id).ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
cfg.feeds.insert(id.clone(), found.cfg.clone()); cfg.feeds.insert(id.clone(), found.cfg.clone());
@@ -1840,7 +1926,7 @@ async fn patch_feed(
if url_changed { if url_changed {
// Refreshing a rotated auth token is the common case; entries and download history // Refreshing a rotated auth token is the common case; entries and download history
// are keyed by feed id, so they survive the change. // are keyed by feed id, so they survive the change.
state.ctx.db.clear_validators(&id).await?; state.ctx.db.clear_validators(&id).instrument(step("clear_validators")).await?;
} }
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -1987,37 +2073,52 @@ async fn fetch_now(
} }
/// The same broadcast the socket clients read, as server-sent events. /// The same broadcast the socket clients read, as server-sent events.
const EVENTS_RECHECK: std::time::Duration = std::time::Duration::from_secs(30);
async fn events( async fn events(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> { ) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
let rx = state.events.subscribe(); let rx = state.events.subscribe();
// Who this is, and what they read, looked up again every half minute: the stream outlives
// the request that opened it, and went on after the account was removed (#335).
let looked: (Option<std::time::Instant>, std::collections::HashSet<String>) = (None, Default::default());
// A client that falls behind skips what it missed rather than being cut off. // A client that falls behind skips what it missed rather than being cut off.
let stream = futures_util::stream::unfold((rx, state, user), |(mut rx, state, user)| async move { let stream =
loop { futures_util::stream::unfold((rx, state, user, looked), |(mut rx, state, mut user, mut looked)| async move {
match rx.recv().await { loop {
Ok(ev) => { match rx.recv().await {
let Ok(data) = serde_json::to_string(&ev) else { continue }; Ok(ev) => {
let mut out = vec![Ok::<_, std::convert::Infallible>(SseEvent::default().data(data))]; if looked.0.is_none_or(|at| at.elapsed() >= EVENTS_RECHECK) {
// A feed that changed goes out as this person's row for it, which the page user = state.ctx.db.user_by_id(user.id).await.ok().flatten()?;
// puts in place of the old one: it reloaded the whole list after each. let subs = state.ctx.db.subscriptions_for(user.id).await.unwrap_or_default();
if let Some(feed) = changed_feed(&ev) looked = (Some(std::time::Instant::now()), subs.into_iter().map(|s| s.feed_id).collect());
&& let Ok(rows) = feed_rows(&state, &user, Some(feed)).await }
&& let Some(row) = rows.first() if !ev.is_for(&user, &looked.1) {
&& let Ok(data) = serde_json::to_string(&serde_json::json!({ "ev": "feed_row", "row": row })) continue;
{ }
out.push(Ok(SseEvent::default().data(data))); let Ok(data) = serde_json::to_string(&ev) else { continue };
let mut out = vec![Ok::<_, std::convert::Infallible>(SseEvent::default().data(data))];
// A feed that changed goes out as this person's row for it, which the page
// puts in place of the old one: it reloaded the whole list after each.
if let Some(feed) = changed_feed(&ev)
&& let Ok(rows) = feed_rows(&state, &user, Some(feed)).await
&& let Some(row) = rows.first()
&& let Ok(data) =
serde_json::to_string(&serde_json::json!({ "ev": "feed_row", "row": row }))
{
out.push(Ok(SseEvent::default().data(data)));
}
return Some((futures_util::stream::iter(out), (rx, state, user, looked)));
} }
return Some((futures_util::stream::iter(out), (rx, state, user))); // Counted for /metrics: a page that misses updates shows stale counts (#195).
Err(broadcast::error::RecvError::Lagged(n)) => {
crate::metrics::STATS.events_missed.fetch_add(n, std::sync::atomic::Ordering::Relaxed);
}
Err(broadcast::error::RecvError::Closed) => return None,
} }
// Counted for /metrics: a page that misses updates shows stale counts (#195).
Err(broadcast::error::RecvError::Lagged(n)) => {
crate::metrics::STATS.events_missed.fetch_add(n, std::sync::atomic::Ordering::Relaxed);
}
Err(broadcast::error::RecvError::Closed) => return None,
} }
} });
});
Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default()) Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default())
} }
@@ -2133,13 +2234,38 @@ async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Reque
Ok(mut r) => { Ok(mut r) => {
// Never in a shared cache: Cloudflare kept `deadair.mp3`, by its extension, and served // Never in a shared cache: Cloudflare kept `deadair.mp3`, by its extension, and served
// its copy to anyone Access let in, without the subscriber check above (#189). // its copy to anyone Access let in, without the subscriber check above (#189).
r.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private")); let h = r.headers_mut();
h.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private"));
// The file and its name are the publisher's, and ServeFile types it by extension, so an
// `.svg` or `.html` opened from the page ran its script as iPX, with the viewer's
// cookies (#319). Media and PDFs open in the browser; anything else is saved.
h.insert(header::X_CONTENT_TYPE_OPTIONS, header::HeaderValue::from_static("nosniff"));
let kind = h.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
if !opens_inline(&kind) {
h.insert(header::CONTENT_DISPOSITION, header::HeaderValue::from_static("attachment"));
}
// Not on a PDF: Chrome refuses to show one in a sandboxed page.
if !kind.starts_with("application/pdf") {
h.insert(header::CONTENT_SECURITY_POLICY, header::HeaderValue::from_static(MEDIA_CSP));
}
r.into_response() r.into_response()
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
const MEDIA_CSP: &str = "default-src 'none'; media-src 'self'; img-src 'self'; style-src 'unsafe-inline'; sandbox";
/// A downloaded file's type that cannot run script on iPX's address when opened on its own.
fn opens_inline(content_type: &str) -> bool {
let t = content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
t.starts_with("audio/")
|| t.starts_with("video/")
|| (t.starts_with("image/") && !t.contains("svg"))
|| t == "application/pdf"
|| t == "text/plain"
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ArtQuery { struct ArtQuery {
u: String, u: String,
@@ -2161,18 +2287,29 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
// Kept only once it was let through below, so a kept image needs no asking again (#272). // Kept only once it was let through below, so a kept image needs no asking again (#272).
let keep = state.ctx.cfg().general.art_cache_mb > 0; let keep = state.ctx.cfg().general.art_cache_mb > 0;
let file = crate::art::path(&q.u); let file = crate::art::path(&q.u);
if keep && let Some((kind, body)) = crate::art::read(&file) { if keep && let Some((kind, body)) = kept_art(&file).await {
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return art_response(kind, body); return art_response(kind, body);
} }
// Only what a feed here or the directory names: anything else would make ipx fetch any // Only what a feed here or the directory names: anything else would make ipx fetch any
// address it is given. The Directory shows podcasts nobody here subscribes to (#273). // address it is given. The Directory shows podcasts nobody here subscribes to (#273).
if !crate::directory::listed_art(&q.u) && !state.ctx.db.names_image(&q.u).await.unwrap_or(false) { if !crate::directory::listed_art(&q.u)
&& !state.ctx.db.names_image(&q.u).instrument(step("names_image")).await.unwrap_or(false)
{
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response(); return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
} }
if let Some(why) = crate::art::failed(&q.u) { if let Some(why) = crate::art::failed(&q.u) {
return (StatusCode::BAD_GATEWAY, why).into_response(); return (StatusCode::BAD_GATEWAY, why).into_response();
} }
// One fetch an address at a time: a page asks for fifty images at once, and a cover two of
// them name was fetched twice on a cold cache (#349). The second waits, then finds it kept.
let gate = ART_FETCHES.lock().unwrap().entry(q.u.clone()).or_default().clone();
let _turn = gate.lock().await;
let _done = ArtFetchDone(&q.u);
if keep && let Some((kind, body)) = kept_art(&file).await {
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return art_response(kind, body);
}
crate::metrics::STATS.art_misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed); crate::metrics::STATS.art_misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let got = async { let got = async {
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?; let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?;
@@ -2186,10 +2323,17 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
let kind = image_kind(&said, &body).ok_or_else(|| anyhow::anyhow!("not an image: {said}"))?; let kind = image_kind(&said, &body).ok_or_else(|| anyhow::anyhow!("not an image: {said}"))?;
anyhow::Ok((kind, body)) anyhow::Ok((kind, body))
}; };
match got.await { match got.instrument(step("art_fetch")).await {
Ok((kind, body)) => { Ok((kind, body)) => {
if keep && let Err(e) = crate::art::write(&file, &kind, &body) { if keep {
tracing::debug!(error = %e, "could not keep the artwork"); let (span, file, kind, body) = (step("art_keep"), file.clone(), kind.clone(), body.clone());
match tokio::task::spawn_blocking(move || span.in_scope(|| crate::art::write(&file, &kind, &body)))
.await
{
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::debug!(error = %e, "could not keep the artwork"),
Err(e) => tracing::debug!(error = %e, "could not keep the artwork"),
}
} }
art_response(kind, body) art_response(kind, body)
} }
@@ -2203,6 +2347,31 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
} }
} }
/// The fetches of artwork under way, by address, each a lock the requests for it take in turn.
static ART_FETCHES: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
> = std::sync::LazyLock::new(Default::default);
/// Forgets an address's lock once nobody else holds it, however the request ended.
struct ArtFetchDone<'a>(&'a str);
impl Drop for ArtFetchDone<'_> {
fn drop(&mut self) {
let mut fetches = ART_FETCHES.lock().unwrap();
// Two: the map's and this request's own.
if fetches.get(self.0).is_some_and(|g| Arc::strong_count(g) <= 2) {
fetches.remove(self.0);
}
}
}
/// Artwork kept on disk, read off the async threads: the cache is on FUSE, and four calls a
/// hit, fifty images a page, held a worker each, 1 s at p90 for a hit (#345).
async fn kept_art(file: &std::path::Path) -> Option<(String, Vec<u8>)> {
let (span, file) = (step("art_cached"), file.to_owned());
tokio::task::spawn_blocking(move || span.in_scope(|| crate::art::read(&file))).await.ok().flatten()
}
/// A type that says nothing about what the body is, which some publishers send with real artwork: /// A type that says nothing about what the body is, which some publishers send with real artwork:
/// Spotify's CloudFront `application/octet-stream`, pippa.io none at all (#279). /// Spotify's CloudFront `application/octet-stream`, pippa.io none at all (#279).
fn untyped(said: &str) -> bool { fn untyped(said: &str) -> bool {
@@ -2367,9 +2536,10 @@ async fn import_opml(
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
// Refused before anything is touched. Nothing reaches the disk either way: an uploaded // Refused before anything is touched. Nothing reaches the disk either way: an uploaded
// file arrives as text, is read here, and is gone when the request ends. // file arrives as text, is read here, and is gone when the request ends.
let doc = let doc = crate::feed::read_opml(&body.xml)
opml::OPML::from_str(&body.xml).map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?; .map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?; let found = crate::opml_feeds(&doc).map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
let (added, already) = crate::subscribe_opml(&state.ctx, found, user.id).await?;
if added > 0 { if added > 0 {
scan_soon(&state, None).await; scan_soon(&state, None).await;
} }
@@ -2631,6 +2801,30 @@ async fn name_span(route: MatchedPath, req: Request, next: Next) -> Response {
/// ///
/// The log view polls `/api/logs`, so logging that path would generate a line per poll /// The log view polls `/api/logs`, so logging that path would generate a line per poll
/// forever -- a feed of nothing but its own requests. /// forever -- a feed of nothing but its own requests.
/// A request that changes something is refused when a browser sent it from another site (#336).
/// The session cookie's SameSite=Lax stopped a form on an unrelated site, but not one on a sibling
/// subdomain, nor anything signed in by Cloudflare Access's own cookie; a JSON body forced a
/// preflight, and the routes that take none (Read all, Download, Sign out) had nothing.
async fn cross_site_refused(req: Request, next: Next) -> Response {
let reads =
matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS);
if !reads && from_elsewhere(req.headers()) {
return (StatusCode::FORBIDDEN, "refused: sent from another site").into_response();
}
next.run(req).await
}
/// Whether a browser says the request came from a page on another site. `Sec-Fetch-Site` says so
/// in every current browser; an older one is judged by `Origin` against `Host`. A request with
/// neither is not a browser's, a script with a token, and is let through.
fn from_elsewhere(h: &HeaderMap) -> bool {
let get = |name: &str| h.get(name).and_then(|v| v.to_str().ok());
if let Some(site) = get("sec-fetch-site") {
return !matches!(site, "same-origin" | "none");
}
get("origin").is_some_and(|origin| origin.split_once("://").map(|(_, host)| host) != get("host"))
}
async fn access_log(req: Request, next: Next) -> Response { async fn access_log(req: Request, next: Next) -> Response {
let path = req.uri().path().to_owned(); let path = req.uri().path().to_owned();
let method = req.method().clone(); let method = req.method().clone();

View File

@@ -1,6 +1,7 @@
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both # 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 # 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. # docs/configuration.md has the rest, Postgres included.
services: services:
ipx: ipx:

View File

@@ -31,13 +31,14 @@ Passwords are read from **stdin**, so they miss the shell history and any `ps` l
```sh ```sh
echo -n 'a good password' | ipx user add ray # local account 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 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 list # who exists, and how each signs in
ipx user rm sam # account, subscriptions and read state 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 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: To avoid even the command line, read it interactively:

View File

@@ -46,10 +46,11 @@ max_age_days = 30 # 0 = keep forever
max_new_per_check = 3 # per feed, per scan. 0 = unlimited max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"] media_types = ["audio", "video"]
art_cache_mb = 500 # artwork kept on disk. 0 = none 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 `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 * **`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, 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 * **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
without this the disk fills with artwork. Empty takes everything. 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]` ## `[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 config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md). and are the same for everyone. See [users.md](users.md).
`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 **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 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. settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
@@ -176,6 +186,7 @@ had it only through the OPML loses it as before. Leaving the OPML yourself keeps
| Variable | Effect | | Variable | Effect |
|---|---| |---|---|
| `IPX_CONFIG` | Config file path | | `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_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_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own | | `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
@@ -195,6 +206,7 @@ The page's container reads only its environment (`web/Caddyfile`):
| `WEB_BIND` | Where to listen; `:8080` by default | | `WEB_BIND` | Where to listen; `:8080` by default |
| `WEB_ROOT` | The built page; `/srv` in the image | | `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) | | `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 | | `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). ipx-directory's settings are in [directory.md](directory.md).

View File

@@ -83,6 +83,9 @@ bind = "0.0.0.0:8100"
metrics_bind = "0.0.0.0:9188" metrics_bind = "0.0.0.0:9188"
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none. # Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
apple_country = "ca" 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://... # The environment variable holding the database URL: postgres://... or sqlite://...
database_url_env = "DIRECTORY_DATABASE_URL" database_url_env = "DIRECTORY_DATABASE_URL"

View File

@@ -92,8 +92,8 @@ The recent half reads the index `entries_recent`, on each feed's items by date,
is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274). 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 Rows for one item and one person are grouped, so an item is one call whatever the number of
Signals. On the server's key, everyone's Signals on the same item go in one call too, its text Signals. Two people's Signals never share a call, even on the server's key, where they did until
sent once (#275). Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms #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', 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 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 second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its

View File

@@ -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 3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck
and any scripts use. and any scripts use.
A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in A database with no accounts creates **admin** on the next daemon start, with the password in
the log. Change it: `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 ```sh
echo -n 'a good password' | ipx user passwd admin echo -n 'a good password' | ipx user passwd admin

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ipx-web", "name": "ipx-web",
"version": "1.2.0", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ipx-web", "name": "ipx-web",
"version": "1.2.0", "version": "1.3.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "7.3.1", "@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2", "@swc/core": "^1.16.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ipx-web", "name": "ipx-web",
"version": "1.2.0", "version": "1.3.0",
"private": true, "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.", "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": { "scripts": {

View File

@@ -11,6 +11,7 @@ const blocks = {};
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) { for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
const vars = {}; const vars = {};
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase(); 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; if (Object.keys(vars).length) blocks[m[1]] = vars;
} }
@@ -49,6 +50,21 @@ for (const sel of Object.keys(blocks)) {
} }
themes['modern'] = base; 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; let bad = 0;
for (const [name, p] of Object.entries(themes)) { for (const [name, p] of Object.entries(themes)) {
for (const [fg, grounds, min] of RULES) for (const g of grounds) { for (const [fg, grounds, min] of RULES) for (const g of grounds) {

View File

@@ -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; } }, 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, value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){}, 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 []; }, replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } }, 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) }); { get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });

View File

@@ -134,6 +134,8 @@ download_dir = "${root}/downloads"
socket = "${root}/ipx.sock" socket = "${root}/ipx.sock"
schedule = "every 60m" schedule = "every 60m"
max_new_per_check = 1 max_new_per_check = 1
# The generated feeds are on loopback, which ipx refuses otherwise (#320).
fetch_internal = ["127.0.0.1"]
[torrent] [torrent]
enabled = false enabled = false

View File

@@ -27,6 +27,14 @@ test('esc makes text safe to put in markup and attributes', () => {
assert.equal(run('esc(undefined)'), ''); 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', () => { test('clock reads seconds as a player does', () => {
assert.equal(run('clock(0)'), '0:00'); assert.equal(run('clock(0)'), '0:00');
assert.equal(run('clock(65)'), '1:05'); assert.equal(run('clock(65)'), '1:05');

View File

@@ -694,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. // 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/users')).status()).toBe(403);
expect((await page.request.get('/api/logs')).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 // Subscribing to a feed the admin already has costs no second fetch: same feed, same
// files, but Sam's own read state. // files, but Sam's own read state.
@@ -1906,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'); 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.describe('on a narrow phone', () => {
test.use({ viewport: { width: 360, height: 760 } }); test.use({ viewport: { width: 360, height: 760 } });
@@ -2291,3 +2320,53 @@ signalsTest('the server pays for Signals only when it has a key to pay with', as
await page.request.patch('/api/settings', { data: { signals_on_server: false } }); 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);
});

View File

@@ -32,6 +32,8 @@ download_dir = "${path.join(root, 'downloads')}"
socket = "${path.join(root, 'ipx.sock')}" socket = "${path.join(root, 'ipx.sock')}"
schedule = "every 60m" schedule = "every 60m"
max_new_per_check = 1 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_url = "${DIRECTORY}"
directory_token = "${DIRECTORY_TOKEN}" directory_token = "${DIRECTORY_TOKEN}"
directory_share = true directory_share = true
@@ -75,6 +77,8 @@ auto_download = false
fs.writeFileSync(path.join(root, 'directory.toml'), ` fs.writeFileSync(path.join(root, 'directory.toml'), `
bind = "127.0.0.1:8795" bind = "127.0.0.1:8795"
database_url_env = "DIRECTORY_DATABASE_URL" 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]] [[tokens]]
name = "test" name = "test"

View File

@@ -11,6 +11,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.2.0] - 2026-10-19
### Added ### Added
@@ -86,7 +113,8 @@ 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). - 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.2.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.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.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.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2

View File

@@ -78,6 +78,9 @@ http://{$WEB_BIND::8080} {
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255} @forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
request_header @forged -Cf-Access-Authenticated-User-Email request_header @forged -Cf-Access-Authenticated-User-Email
request_header @forged -Cf-Access-Jwt-Assertion 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/* @api path /api/* /media/*
handle @api { handle @api {

View File

@@ -31,7 +31,7 @@
<section id="directory" hidden></section> <section id="directory" hidden></section>
<section id="log" hidden></section> <section id="log" hidden></section>
</main> </main>
<div id="toasts"></div> <div id="toasts" role="status"></div>
<script data-src="web/src"></script> <script data-src="web/src"></script>
</body> </body>

View File

@@ -20,6 +20,12 @@
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */ --warn:#f5a524; /* amber, apart from the needle's orange, for pending */
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */ --bad:#ff6b7a; /* pushed towards pink, apart from the needle */
--shadow:0 8px 28px rgba(4,10,20,.55); --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 /* Every other theme overrides the same variables, under data-theme and data-mode. The page's
script sets data-mode to light or dark, working Auto out from the system, so each theme has script sets data-mode to light or dark, working Auto out from the system, so each theme has
@@ -132,6 +138,7 @@
--shadow:0 8px 28px rgba(0,0,0,.5); --shadow:0 8px 28px rgba(0,0,0,.5);
} }
:root[data-theme="adwaita"][data-mode="light"] { :root[data-theme="adwaita"][data-mode="light"] {
--fieldmix:65%;
--bg:#ffffff; --bg:#ffffff;
--panel:#ebebed; --panel:#ebebed;
--panel2:#fafafb; --panel2:#fafafb;
@@ -206,6 +213,7 @@
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for /* 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. */ the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
:root[data-theme="nordic"] { :root[data-theme="nordic"] {
--fieldmix:65%;
--bg:#2e3440; /* nord0 */ --bg:#2e3440; /* nord0 */
--panel:#3b4252; /* nord1 */ --panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */ --panel2:#434c5e; /* nord2 */
@@ -243,6 +251,7 @@
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent) mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
and peach for the rest. */ and peach for the rest. */
:root[data-theme="catppuccin"] { :root[data-theme="catppuccin"] {
--fieldmix:70%;
--bg:#1e1e2e; /* base */ --bg:#1e1e2e; /* base */
--panel:#181825; /* mantle */ --panel:#181825; /* mantle */
--panel2:#313244; /* surface0 */ --panel2:#313244; /* surface0 */
@@ -260,6 +269,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45); --shadow:0 8px 28px rgba(0,0,0,.45);
} }
:root[data-theme="catppuccin"][data-mode="light"] { :root[data-theme="catppuccin"][data-mode="light"] {
--fieldmix:80%;
--bg:#eff1f5; /* base */ --bg:#eff1f5; /* base */
--panel:#e6e9ef; /* mantle */ --panel:#e6e9ef; /* mantle */
--panel2:#dce0e8; /* crust */ --panel2:#dce0e8; /* crust */
@@ -296,6 +306,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45); --shadow:0 8px 28px rgba(0,0,0,.45);
} }
:root[data-theme="gruvbox"][data-mode="light"] { :root[data-theme="gruvbox"][data-mode="light"] {
--fieldmix:70%;
--bg:#fbf1c7; /* bg0 */ --bg:#fbf1c7; /* bg0 */
--panel:#f2e5bc; /* bg0_s */ --panel:#f2e5bc; /* bg0_s */
--panel2:#ebdbb2; /* bg1 */ --panel2:#ebdbb2; /* bg1 */
@@ -332,6 +343,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45); --shadow:0 8px 28px rgba(0,0,0,.45);
} }
:root[data-theme="solarized"][data-mode="light"] { :root[data-theme="solarized"][data-mode="light"] {
--fieldmix:65%;
--bg:#fdf6e3; /* base3 */ --bg:#fdf6e3; /* base3 */
--panel:#eee8d5; /* base2 */ --panel:#eee8d5; /* base2 */
--panel2:#e6dfca; --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} .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){ @media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important} *,*::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{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px} ::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
::-webkit-scrollbar-track{background:transparent} ::-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; width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none; color:var(--dim);flex:none;
} }
.iconbtn:hover{background:var(--raise);color:var(--fg)} @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. */ /* 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 /* 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 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
@@ -542,13 +562,13 @@ a{color:var(--accent)}
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3} .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 a{display:inline-grid;place-items:center}
.tgroup button+button,.tgroup button+a{border-left:1px solid var(--line)} .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} .tgroup button:disabled{opacity:.35;cursor:default}
.sidetools button,.sidefoot button{ .sidetools button,.sidefoot button{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px; flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 8px;font-size:12.5px;color:var(--dim); 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. */ /* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
.sidefoot{ .sidefoot{
display:flex;flex-direction:column;gap:7px;padding:8px 12px; display:flex;flex-direction:column;gap:7px;padding:8px 12px;
@@ -558,15 +578,17 @@ a{color:var(--accent)}
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)} .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 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{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 button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75} .sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:12px 12px 8px} .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{ 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; 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 /* 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. */ (#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} .clearable{position:relative;display:flex;align-items:center;min-width:0}
@@ -574,18 +596,18 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.clearable>input[type=search]{flex:1;min-width:0;padding-right:32px} .clearable>input[type=search]{flex:1;min-width:0;padding-right:32px}
.clearable>input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none} .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)} .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)}
.clearx:hover{color:var(--fg)} @media (hover:hover){.clearx:hover{color:var(--fg)}}
.clearx svg{width:11px;height:11px} .clearx svg{width:11px;height:11px}
.clearable:has(>input:placeholder-shown) .clearx{display:none} .clearable:has(>input:placeholder-shown) .clearx{display:none}
.inline .clearable{flex:1} .inline .clearable{flex:1}
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the /* 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. */ 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{ .feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px; display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px;position:relative; 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)} .feed.sel{background:var(--raise)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */ /* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px} .feed.child{margin-left:11px}
@@ -614,7 +636,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
max-height:220px;overflow-y:auto;margin-top:8px; max-height:220px;overflow-y:auto;margin-top:8px;
} }
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)} .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} .iconcell .ficon{width:20px;height:20px}
/* Auto in the icon picker, while an icon is picked: the scan spinner, beside the word. */ /* Auto in the icon picker, while an icon is picked: the scan spinner, beside the word. */
.dlink.busy::after{ .dlink.busy::after{
@@ -628,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; position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
border-radius:4px;color:var(--faint); 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)} .chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed being checked: a small spinner in place of its unread count (issue #37). */ /* A feed being checked: a small spinner in place of its unread count (issue #37). */
.feed.scanning .badge{display:none} .feed.scanning .badge{display:none}
@@ -658,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); display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
border-radius:9px;cursor:pointer;background:var(--panel); 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 .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0} .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} .childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -686,7 +709,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.dhead{margin-bottom:6px} .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{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 .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{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} .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} .dhead .sub{margin:5px 0 0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
@@ -694,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{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} .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{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. */ /* 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} .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)} .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)}
@@ -712,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; display:flex;flex-direction:column;align-items:flex-start;
background:var(--panel);border:1px solid var(--line); 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 /* 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. */ 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} .cat b{font-size:16px;line-height:1.2;font-weight:650;color:var(--fg);overflow-wrap:break-word}
@@ -724,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, 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 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. */ 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); 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(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(2){right:25px;rotate:-3deg;translate:0 -4px}
.cat .fan .art:nth-last-child(3){right:50px;rotate:-13deg;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} @media (hover:hover){.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} @media (hover:hover){.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(3){rotate:-19deg;translate:-7px 3px}}
/* A category's subcategories, each with how many it holds, then the order. A picked one is /* A category's subcategories, each with how many it holds, then the order. A picked one is
drawn as a picked tab is. */ drawn as a picked tab is. */
.dtools{display:flex;align-items:center;gap:10px 14px;margin-bottom:14px} .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{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{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 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} .pills button.on{background:var(--raise);border-color:var(--raise);color:var(--fg);font-weight:500}
.dtools .grow{flex:1} .dtools .grow{flex:1}
#dirsort{flex:none;width:auto;padding:4px 6px;font-size:13px} #dirsort{flex:none;width:auto;padding:4px 6px;font-size:13px}
@@ -753,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} .tile .txt small{display:block;color:var(--faint);font-size:11.5px}
/* Every one opens: yours its feed, the rest their page here. */ /* Every one opens: yours its feed, the rest their page here. */
.tile,.rank{cursor:pointer} .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 /* 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. */ here reads it beside it, then what it has published lately. */
.show{display:flex;gap:22px;align-items:flex-end;margin-top:4px} .show{display:flex;gap:22px;align-items:flex-end;margin-top:4px}
@@ -764,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 .dfail{color:var(--bad)}
.show .acts{margin-top:8px} .show .acts{margin-top:8px}
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left} .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. */ /* What the feed says it is, three lines of it until More. */
.about{max-width:760px;margin-top:18px} .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} .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}
@@ -774,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{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{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{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 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} .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 /* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
@@ -788,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; display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
} }
.art>.tile{width:100%;height:100%;display:block} .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} #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 /* 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. */ page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
@@ -851,7 +878,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
#files .empty{padding:24px 0} #files .empty{padding:24px 0}
.fhd{font-size:12px;color:var(--faint)} .fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)} #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)} #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} .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} .dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
@@ -875,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 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{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none} .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{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{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7} .acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn:hover{background:var(--raise)} @media (hover:hover){.acts .btn:hover{background:var(--raise)}}
.btn{ .btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px; background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px; padding:6px 12px;font-size:13px;
} }
.btn:hover{border-color:var(--accent)} @media (hover:hover){.btn:hover{border-color:var(--accent)}}
a.btn{text-decoration:none;color:inherit} a.btn{text-decoration:none;color:inherit}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600} .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
.btn.primary:hover{filter:brightness(1.08)} @media (hover:hover){.btn.primary:hover{filter:brightness(1.08)}}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)} @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. */ /* 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} .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. */ /* Inline in a sentence, next to a plain-word error explanation. */
@@ -913,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; 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; 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{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 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} .tabs a{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim);text-decoration:none}
@@ -953,7 +980,8 @@ body.adminpage #log{max-width:none}
/* Each heading sorts by its column; the caret says which way. */ /* 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; .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} 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{display:inline-flex}
.ephead .hs .arr .i{width:8px;height:8px} .ephead .hs .arr .i{width:8px;height:8px}
.ephead .hs .arr.asc{transform:rotate(-90deg)} .ephead .hs .arr.asc{transform:rotate(-90deg)}
@@ -965,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{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0} .ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)} .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 /* 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. */ 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 /* No padding: a button keeps the browser's side padding, which left too little room for the 16px
@@ -991,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(1){animation-delay:-.3s}
body.playing .eq i:nth-child(3){animation-delay:-.6s} body.playing .eq i:nth-child(3){animation-delay:-.6s}
@keyframes eq{from{transform:scaleY(.35)}} @keyframes eq{from{transform:scaleY(.35)}}
.ep .st:hover,.ep .fl:hover{background:var(--raise)} @media (hover:hover){.ep .st:hover,.ep .fl:hover{background:var(--raise)}}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ep .t{font-weight:600;font-size:13.5px;display:block;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. */ /* An item with no title shows its opening words: set as text, not as a heading. */
.ep .t.notitle{font-weight:400} .ep .t.notitle{font-weight:400}
.ep.read .t{color:var(--dim);font-weight:500} .ep.read .t{color:var(--dim);font-weight:500}
@@ -1009,7 +1037,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
#split.one .h-fd,#split.one .ep .fd{display:none} #split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex: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 .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{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)} .dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s} .dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
@@ -1047,7 +1076,7 @@ body.has-video #audio{
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg); width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
border-radius:50%; 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)} #seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
input[type=range]{ input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1; -webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
@@ -1067,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; place-items:center;z-index:50;padding:20px;
} }
#modal.on{display:grid} #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{ .card{
background:var(--panel);border:1px solid var(--line);border-radius:14px; 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; padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
overscroll-behavior:contain;
} }
.card.wide{width:min(1000px,100%)} .card.wide{width:min(1000px,100%)}
#logbox{ #logbox{
@@ -1107,7 +1139,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been /* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
three screens down. */ three screens down. */
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size} .card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size}
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px} .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)} .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:20px} .fsethead{display:flex;gap:14px;align-items:center;margin-bottom:20px}
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px} .fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
@@ -1138,10 +1170,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.switch.lead{font-weight:600} .switch.lead{font-weight:600}
.sw{position:relative;flex:none;width:42px;height:26px} .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 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. */ /* 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 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)} .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:checked+i::after{transform:translateX(16px)}
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px} .sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of /* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
@@ -1196,11 +1230,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.flabel{font-size:14px;font-weight:500} .flabel{font-size:14px;font-weight:500}
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)} .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. */ /* 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)} .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} .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{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} .chipx .i{width:9px;height:9px}
.chips .chipin{flex:1 0 8ch;width:auto;min-width:0;padding:4px 6px;border:0;background:none} .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} #toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
@@ -1209,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; 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. */ /* 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)}} @keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none} #burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */ /* Totals for what is showing, along the bottom, as the original's status bar. */
@@ -1235,7 +1271,7 @@ body:has(#player.on) #status{padding-bottom:3px}
/* Places above the feeds: not subscriptions, but where to look. */ /* Places above the feeds: not subscriptions, but where to look. */
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px} .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{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.sel{background:var(--raise)}
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none} .place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
.place b{flex:1;font-weight:500;font-size:13.5px} .place b{flex:1;font-weight:500;font-size:13.5px}
@@ -1353,10 +1389,8 @@ body:has(#player.on) #status{padding-bottom:3px}
#player{border-top-color:var(--edge)} #player{border-top-color:var(--edge)}
.sidefoot{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 /* Buttons: filled and round. */
tapped, which left an accent outline on it. */
.btn,.sidetools button,.sidefoot button,.tgroup,.toast,kbd,#logbox{border-color:var(--edge)} .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,.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{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.btn.ico,#dback{min-width:40px;height:40px;padding:0 10px;display:inline-grid;place-items:center} .btn.ico,#dback{min-width:40px;height:40px;padding:0 10px;display:inline-grid;place-items:center}
@@ -1368,7 +1402,7 @@ 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. */ /* 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{ 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 34px 9px 14px} #topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 34px 9px 14px}
input:focus,select:focus{border-color:var(--accent)} input:focus,select:focus{border-color:var(--accent)}
@@ -1393,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. */ /* 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{padding:10px 8px;border-radius:10px;min-height:44px}
.ep.sel{border-color:var(--edge)} .ep.sel{border-color:var(--edge)}
.ep:hover:not(.sel){background-color:transparent}
.ep .t{font-size:17px} .ep .t{font-size:17px}
.ep .line,.ep .date{font-size:15px} .ep .line,.ep .date{font-size:15px}
.ep+.ep{background-image:linear-gradient(var(--line),var(--line));background-size:calc(100% - 64px) 1px;background-position:64px 0;background-repeat:no-repeat} .ep+.ep{background-image:linear-gradient(var(--line),var(--line));background-size:calc(100% - 64px) 1px;background-position:64px 0;background-repeat:no-repeat}
@@ -1436,7 +1469,7 @@ body:has(#player.on) #status{padding-bottom:3px}
.dpends{font-size:14px} .dpends{font-size:14px}
/* 16px or more, or Safari zooms the page in on it. */ /* 16px or more, or Safari zooms the page in on it. */
.wcap input[type=number]{font-size:16px;padding:3px 4px} .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} .chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
.chipx{width:26px;height:26px} .chipx{width:26px;height:26px}
.field.boxed{border-radius:14px} .field.boxed{border-radius:14px}
@@ -1465,9 +1498,11 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="classic"] .btn, :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{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"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button:hover:not(:disabled), @media (hover:hover){
:root[data-theme="classic"] .tgroup a:hover, :root[data-theme="classic"] .tgroup button:hover:not(:disabled),
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)} :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"] .btn.primary,
:root[data-theme="classic"] .tabs button.on, :root[data-theme="classic"] .tabs button.on,
:root[data-theme="classic"] .feed.sel, :root[data-theme="classic"] .feed.sel,
@@ -1502,7 +1537,8 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="classic"] .ep.sel .fl, :root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file, :root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind, :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. */ /* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6} :root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad} :root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}

View File

@@ -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> <button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
</div> </div>
<span class="grow"></span> <span class="grow"></span>
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span> <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"> <div class="tgroup" id="admintools">
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button> <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> <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> </header>
<div id="shell"> <div id="shell">
<aside id="sidebar"> <aside id="sidebar">
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></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 id="feedlist"></div>
<div class="sidefoot"> <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> <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>
<div id="seekrow"> <div id="seekrow">
<span id="pcur">0:00</span> <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> <span id="pdur">0:00</span>
</div> </div>
</div> </div>
@@ -78,8 +78,8 @@
</div> </div>
</div> </div>
<div id="modal"><div class="card" id="modalCard"></div></div> <div id="modal"><div class="card" id="modalCard" role="dialog" aria-modal="true" tabindex="-1"></div></div>
<div id="toasts"></div> <div id="toasts" role="status"></div>
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same <!-- 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 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. --> the current file is video -- see body.has-video below. -->

View File

@@ -1,21 +1,43 @@
/* ---------------- modals ---------------- */ /* ---------------- modals ---------------- */
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog. /// `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){ function openModal(html: string, wide?: boolean, cls?: string){
$('#modalCard').innerHTML=html; const m=$('#modal'), card=$('#modalCard');
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:''); card.innerHTML=html;
$('#modal').classList.add('on'); 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(){ 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(); }; $('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$<HTMLButtonElement>('#addFeed').onclick=()=>{ $<HTMLButtonElement>('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3> 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> <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> <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> <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> <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. // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>` (p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`); :`<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. // 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; } if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); }; 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=>{ list.innerHTML=items.length?items.map(e=>{
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500); 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); 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>`:'') +(text?`<p>${esc(text)}</p>`:'')
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>'; +(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>'; }).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 g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin); 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> 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])=> <select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div> `<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
<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])=> <select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select> `<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> <span class="hint">Auto follows your system's light/dark setting.</span></div>
@@ -461,7 +484,8 @@ async function prefsModal(){
Anyone holding one is you here, so revoke one you no longer use.</span></div> Anyone holding one is you here, so revoke one you no longer use.</span></div>
<div class="field"><label>Feeds are checked every</label> <div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`); ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
<div class="field"><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>('#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); $<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
$<HTMLButtonElement>('#gopml').onclick=opmlModal; $<HTMLButtonElement>('#gopml').onclick=opmlModal;
@@ -745,7 +769,7 @@ function settingsModal(f: Feed, newUrl?: string){
function downloadLatestModal(f: Feed){ function downloadLatestModal(f: Feed){
openModal(`<h3>Download latest items</h3> 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"> <input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div> <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> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
@@ -781,9 +805,9 @@ function opmlModal(){
<div class="inline"> <div class="inline">
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a> <a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
</div></div> </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"> <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> <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>`); <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()=>{ $<HTMLButtonElement>('#oimp').onclick=async()=>{
@@ -818,6 +842,7 @@ api('/api/me').then(u=>{
S.me=u; S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':''); $('#who').textContent=u.name+(u.admin?' · admin':'');
// ipx's version and the page's, each released on its own. // 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__`; $('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
// Whether the link to Admin shows, now and before the first frame of the next visit. // Whether the link to Admin shows, now and before the first frame of the next visit.
document.documentElement.toggleAttribute('data-admin',!!u.admin); document.documentElement.toggleAttribute('data-admin',!!u.admin);

View File

@@ -50,7 +50,9 @@ function connect(){
else if(ev.ev==='download_error'){ else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries(); // 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. // Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
else if(ev.ev==='signal_hits'){ else if(ev.ev==='signal_hits'){

View File

@@ -1,4 +1,21 @@
/* ---------------- feed page ---------------- */ /* ---------------- 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(){ function renderFeed(){
paintScanning(); // another feed's page may be the one open now paintScanning(); // another feed's page may be the one open now
const box=$('#content'); const box=$('#content');
@@ -20,9 +37,7 @@ function renderFeed(){
${artHTML(f.image,name)} ${artHTML(f.image,name)}
<div class="meta"> <div class="meta">
<h2>${esc(name)}</h2> <h2>${esc(name)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${ ${statHTML(f)}
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>
${failBannerHTML(f)} ${failBannerHTML(f)}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its ${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>`:''} OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}

View File

@@ -5,6 +5,7 @@ let rowsQueued=0;
function patchFeed(row: Feed){ function patchFeed(row: Feed){
const i=S.feeds.findIndex(f=>f.id===row.id); const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row; 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(); }); if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
} }
@@ -60,7 +61,16 @@ function paintScanning(){
if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); 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. // 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>, /// 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 /// because a feed's page is redrawn as its items come in and would take a class on the button
@@ -68,15 +78,16 @@ function paintScanning(){
/// between one feed and the next, many times a second (#231). /// between one feed and the next, many times a second (#231).
let spinFeed: string|null=null; let spinFeed: string|null=null;
function spinStart(feed: string|null){ function spinStart(feed: string|null){
if(feed===null) document.body.classList.add('scan-any'); if(feed===null) document.body.classList.add('scan-any'); else spinFeed=feed;
else { spinFeed=feed; paintScanning(); } paintScanning();
} }
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves /// `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 /// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
/// (an error ended the job) stops them all. /// (an error ended the job) stops them all.
function spinEnd(done?: {only: string|null, forced: boolean}){ 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==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(){ function renderFeeds(){
@@ -105,6 +116,7 @@ function renderFeeds(){
el.tabIndex=0; el.dataset.id=id; el.tabIndex=0; el.dataset.id=id;
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all' 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>`:''); ?`<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.onclick=()=>{ selectFeed(id); nav(false); };
el.onpointerenter=el.ontouchstart=()=>prefetch(id); el.onpointerenter=el.ontouchstart=()=>prefetch(id);
places.appendChild(el); places.appendChild(el);
@@ -168,6 +180,7 @@ function renderFeeds(){
`</small></div>`+ `</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+ (f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
asLink(el,S.feed===f.id);
el.onclick=()=>{ selectFeed(f.id); nav(false); }; el.onclick=()=>{ selectFeed(f.id); nav(false); };
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247). // Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id); el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
@@ -188,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 /// 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. /// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f: Feed){ 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>${ <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>`; 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 ''; return '';
} }
function wireFailBanner(box: ParentNode,f: Feed){ function wireFailBanner(box: ParentNode,f: Feed){

View File

@@ -51,7 +51,7 @@ function refreshFeed(){
document.body.append(spin); document.body.append(spin);
// New items arrive by the event stream when the scan finishes, as they do for a button press. // New items arrive by the event stream when the scan finishes, as they do for a button press.
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})}) const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true)); .catch(e => toast(why(e), true));
loadEntries(); loadEntries();
// Up for two seconds at least, so it is seen even when the server answers at once. // 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; }); Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });

View File

@@ -162,13 +162,15 @@ function epEl(e: Entry){
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
const name=entryName(e); 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=` el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${ <button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button> 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="${ <button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button> e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body"> <div class="body">
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span> <button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
<div class="line">${[ <div class="line">${[
num&&`<span>${num}</span>`, num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`, left&&`<span>${left}</span>`,
@@ -283,7 +285,7 @@ function setRead(e: Entry,read: boolean){
/// reloaded, so an item does not vanish from under the pointer on the Unread tab. /// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e: Entry){ function markRead(e: Entry){
if(e.read) return; 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 /// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
@@ -413,7 +415,7 @@ function detailHtml(e: Entry){
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button> aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}" <button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button> aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer" ${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> 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> <button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
</div> </div>
@@ -474,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 // Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied // straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay. // through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held'; const viewable = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
return `<div class="encbox"> return `<div class="encbox">
${kindIcon(x)} ${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>

View File

@@ -165,7 +165,8 @@ function installNativePlayback(){
case 'error': case 'error':
N.paused = true; N.paused = true;
fire('pause'); 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; break;
} }
}, },

View File

@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
return ready.then(()=>{ return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192). // 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=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}); } }); report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio); hls.loadSource(url); hls.attachMedia(audio);
}); });
@@ -48,10 +48,18 @@ function markPlayed(){
setRead(e,true).catch(()=>{}); 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. /// 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)){ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
if(!enc||!isPlayable(enc)){ 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; return;
} }
// The same file carries on where it was; another of the item's files starts from its top. So // 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; player.marked=false;
paintDead(); paintDead();
} }
const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true)); const go=()=>audio.play().catch(playFailed);
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go(); if(wait) wait.then(go,playFailed); else go();
renderEntries(); renderEntries();
} }
function mediaSession(e: Entry,f: Feed|undefined){ function mediaSession(e: Entry,f: Feed|undefined){
@@ -95,6 +103,8 @@ audio.addEventListener('timeupdate',()=>{
$('#pcur').textContent=clock(audio.currentTime); $('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d); $('#pdur').textContent=clock(d);
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000)); 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 // 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. // that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true; 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 // The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled. // they are disabled.
o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(), 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(), A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }, r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
'[':()=>matchMedia('(max-width:820px)').matches '[':()=>matchMedia('(max-width:820px)').matches

View File

@@ -30,6 +30,7 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
const el=document.createElement('div'); const el=document.createElement('div');
el.className='feed '+cls+(S.feed===id?' sel':''); el.className='feed '+cls+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html; el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
asLink(el,S.feed===id);
el.onclick=()=>{ open(); nav(false); }; el.onclick=()=>{ open(); nav(false); };
return el; return el;
} }

View File

@@ -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)]; 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. /// 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 why = (e: unknown): string => e instanceof Error ? e.message : String(e);
const ESCAPED: Record<string,string> = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}; /// 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> ={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]); 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 // 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 // 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 // ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
@@ -116,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)})); window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e.reason?.message||e.reason)}));
async function api(url: string, opts?: RequestInit): Promise<any>{ 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'); } 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 if(!r.ok){
// whole 502 page (#219). // A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim()); // 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); 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 // navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
@@ -175,10 +195,19 @@ async function share(title: string, url: string, btn?: HTMLElement){
} }
function toast(msg: string, bad?: boolean){ 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; t.className='toast'+(bad?' bad':''); t.textContent=msg;
$('#toasts').appendChild(t); const go=()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)};
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200); // 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) => { const clock = (s: number) => {
s=Math.max(0,Math.floor(s||0)); s=Math.max(0,Math.floor(s||0));