Fix cover updates, and accept a cover URL #26

Merged
arne merged 3 commits from cover-updates into main 2026-10-09 11:17:53 +02:00
Owner

Replacing a cover appeared to do nothing, and chasing that turned up two
adjacent bugs plus a missing affordance.

Stale covers. /cover/{uuid} is a stable URL served with
max-age=86400, so once new bytes landed on disk the browser kept
showing the copy it still considered fresh, for up to a day. Now served
no-cache; ServeFile already sets Last-Modified, so revalidation is
a 304 in the common case. internal/kobo/cover.go still has the same
public, max-age=86400 on an equally stable URL — left alone here since
it is device-facing.

Silently swallowed failures. The cover-replacement path discarded
every error and redirected 303 exactly like a success, which is why a
cover that never changed gave nothing to go on. Failures now render on
the edit page with the reason.

PNG stored as .jpg. The form accepted image/png while WriteCover
always named the file .jpg and both cover handlers announce
image/jpeg. WriteCover now normalises — JPEG byte-for-byte, PNG
re-encoded, anything else ErrUnsupportedImage — rather than making the
extension dynamic, which RenameCover, Archive, Restore and the
Kobo handler would all have to agree on. An epub with an unstorable
cover now loses its thumbnail instead of failing the import, and the
admin API answers 415 when a body is not the image its Content-Type
claimed.

Cover URLs. A cover_url field on the edit form, fetched
server-side, so a cover found online need not be downloaded and
re-uploaded by hand. An attached file wins over a URL left in the field.

The server making that request is the part worth care: the URL is
attacker-chosen input even though only the owner can submit it, and this
host shares networks with the identity provider, the vault and its own
admin API on localhost. internal/coverfetch is the single place that
decides what is reachable — http/https only, and a dial-time check of
the resolved address, so a hostname pointing into private space and
any redirect hop are both caught where inspecting the URL string would
miss them. Blocked: loopback, RFC1918, link-local, IPv6 ULA, and
100.64.0.0/10 explicitly, since net.IP.IsPrivate does not cover it
and it is the tailnet range. Content-Type is not trusted; the
magic-byte check stays the authority. 15s timeout, 20 MiB cap enforced
on the body rather than on a Content-Length that can lie.

Testing

go vet clean, full suite green, CGO_ENABLED=0 build OK.

Added: PNG re-encode, unsupported rejection, lying Content-Type → 415,
a successful multipart upload, a rejected cover asserting 400 with the
message visible and CoverPath left unset, 9 coverfetch tests
(including redirect-to-private and a blocked/allowed address table), and
6 web-level URL tests — stored, empty URL is not a change, bad scheme,
private address refused and never fetched, upstream status surfaced,
and file-beats-URL asserting the URL server was hit zero times.

Existing cover fixtures were string literals like []byte("jpg-bytes"),
which the format check now rejects, so they generate real images.

Note

One existing library file, Eric Matthes/Python Crash Course.jpg, is
PNG bytes in a .jpg (the only one of 129). It renders because browsers
sniff; re-uploading or re-importing normalises it. Not migrated here.

🤖 Generated with Claude Code

Replacing a cover appeared to do nothing, and chasing that turned up two adjacent bugs plus a missing affordance. **Stale covers.** `/cover/{uuid}` is a stable URL served with `max-age=86400`, so once new bytes landed on disk the browser kept showing the copy it still considered fresh, for up to a day. Now served `no-cache`; `ServeFile` already sets `Last-Modified`, so revalidation is a 304 in the common case. `internal/kobo/cover.go` still has the same `public, max-age=86400` on an equally stable URL — left alone here since it is device-facing. **Silently swallowed failures.** The cover-replacement path discarded every error and redirected 303 exactly like a success, which is why a cover that never changed gave nothing to go on. Failures now render on the edit page with the reason. **PNG stored as .jpg.** The form accepted `image/png` while `WriteCover` always named the file `.jpg` and both cover handlers announce `image/jpeg`. `WriteCover` now normalises — JPEG byte-for-byte, PNG re-encoded, anything else `ErrUnsupportedImage` — rather than making the extension dynamic, which `RenameCover`, `Archive`, `Restore` and the Kobo handler would all have to agree on. An epub with an unstorable cover now loses its thumbnail instead of failing the import, and the admin API answers 415 when a body is not the image its `Content-Type` claimed. **Cover URLs.** A `cover_url` field on the edit form, fetched server-side, so a cover found online need not be downloaded and re-uploaded by hand. An attached file wins over a URL left in the field. The server making that request is the part worth care: the URL is attacker-chosen input even though only the owner can submit it, and this host shares networks with the identity provider, the vault and its own admin API on localhost. `internal/coverfetch` is the single place that decides what is reachable — http/https only, and a dial-time check of the *resolved* address, so a hostname pointing into private space and any redirect hop are both caught where inspecting the URL string would miss them. Blocked: loopback, RFC1918, link-local, IPv6 ULA, and `100.64.0.0/10` explicitly, since `net.IP.IsPrivate` does not cover it and it is the tailnet range. `Content-Type` is not trusted; the magic-byte check stays the authority. 15s timeout, 20 MiB cap enforced on the body rather than on a `Content-Length` that can lie. ## Testing `go vet` clean, full suite green, `CGO_ENABLED=0` build OK. Added: PNG re-encode, unsupported rejection, lying `Content-Type` → 415, a successful multipart upload, a rejected cover asserting 400 with the message visible and `CoverPath` left unset, 9 `coverfetch` tests (including redirect-to-private and a blocked/allowed address table), and 6 web-level URL tests — stored, empty URL is not a change, bad scheme, private address refused *and never fetched*, upstream status surfaced, and file-beats-URL asserting the URL server was hit zero times. Existing cover fixtures were string literals like `[]byte("jpg-bytes")`, which the format check now rejects, so they generate real images. ## Note One existing library file, `Eric Matthes/Python Crash Course.jpg`, is PNG bytes in a `.jpg` (the only one of 129). It renders because browsers sniff; re-uploading or re-importing normalises it. Not migrated here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Replacing a cover appeared to do nothing. /cover/{uuid} is a stable URL
served with max-age=86400, so after the new bytes landed on disk the
browser kept showing the copy it still considered fresh, for up to a
day. Serve it no-cache instead: ServeFile already sets Last-Modified,
so the revalidation is a 304 in the common case.

The edit form also accepts image/png while WriteCover always named the
file .jpg and both cover handlers always announce image/jpeg. Normalise
on write — JPEG passes through byte-for-byte, PNG is re-encoded,
anything else is ErrUnsupportedImage — rather than make the extension
dynamic, which RenameCover, Archive, Restore and the Kobo cover handler
would all have to agree on.

That sentinel needs handling where covers come from elsewhere: an epub
declaring a cover we cannot store now loses its thumbnail instead of
failing the import, and the admin API answers 415 when a body is not
the image its Content-Type claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a cover_url field to the edit form, fetched server-side, so a cover
found online does not have to be downloaded and re-uploaded by hand. An
attached file still wins over a URL left in the field.

The server making the request is the part worth being careful about: the
URL is attacker-chosen input even though only the owner can submit it,
and this host shares networks with the identity provider, the vault and
its own admin API on localhost. internal/coverfetch is the single place
that decides what is reachable — http/https only, and a dial-time check
of the resolved address, so a hostname pointing into private space and
any redirect hop are both caught where inspecting the URL string would
miss them. Blocked: loopback, RFC1918, link-local, IPv6 ULA, and
100.64.0.0/10 explicitly, since net.IP.IsPrivate does not cover it and
it is the tailnet range. Content-Type is not trusted; WriteCover's
magic-byte check stays the authority. 15s timeout, 20 MiB read cap
enforced on the body rather than on a Content-Length that can lie.

Cover failures now render on the edit page with the reason — bad scheme,
private address, too large, or the upstream's own status. Previously the
upload path discarded every error and redirected 303 exactly like a
success, which is why a cover that never changed gave nothing to go on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tea is not installed on the host that cuts releases; forge is, and is
authenticated for code.bas.es. The one behavioural difference worth
knowing: tea attached assets in the same call that created the tag,
while forge uploads each asset separately, so an interrupted release can
exist without its binary — the comment says how to recover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
arne merged commit c10b688437 into main 2026-10-09 11:17:53 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
arne/books!26
No description provided.