* feat(security): opt-in recoverable gallery passwords
Gallery passwords are bcrypt hashes, so an admin who needs to hand a
password to a client a second time has to reset it, which invalidates
what the client already has. This adds a security setting,
security_gallery_password_recoverable, off by default, that keeps an
AES-256-GCM encrypted copy of each gallery password and client PIN next
to the hash. The key is derived from GALLERY_PASSWORD_ENCRYPTION_KEY or
JWT_SECRET.
While the setting is on:
- create, publish, send-later, edit, reset and the v1 API write the copy
alongside the hash; turning a gallery's password requirement off
clears it
- GET /api/admin/events/:id/password returns the copy to admins with
events.edit and ownership, and writes a gallery_password_viewed
activity entry on every real reveal
- resend-email uses the stored password instead of the "set at creation"
sentinel, so the client receives what already works
Switching the setting off purges every stored copy. Login and hash
verification are untouched; the copy is never read on the gallery side.
The Security tab carries the toggle with a warning that stays visible,
and the event page shows "Show password" with copy buttons only while
the setting is on and the gallery has a secret.
Relates to issue 1271
* fix(security): close the write-versus-switch-off race in the password vault
The recoverable setting is read while an event insert is assembled and the
client-PIN hash awaits after that, so a settings request that switched the
feature off and purged in that gap was overtaken by the insert. Every write
site now re-reads the setting right after its statement and clears its own
row when the setting is off; the settings writer flips the value before it
purges, so either the purge or the re-check catches the row.
* fix(security): resend carries the stored client PIN and link; deterministic tamper test
The creation mail includes the client-access link and PIN; a resend only
sent the gallery password even when a stored PIN was available. The
ciphertext tamper assertion replaced the last two characters with a
constant, which was a no-op roughly once in 4096 runs.
* fix(security): drop the revealed password after Send gallery email
The send-later route can replace the password; the share card keys its
revealed copy on the event query's refetch time, so invalidate the event
after the send like the other password-changing mutations do.
* fix(security): purge leftovers before the setting write when turning recovery on
Switching on wrote the setting first and purged after, so a password write
that read the new "on" in between stored a copy the purge then deleted.
Turning on now purges before the write; turning off keeps purging after it,
which together with the write-site re-check leaves the vault holding
exactly what was written while the setting was on.
* chore(security): drop the duplicate rateLimitService import left by the rebase
* chore(usage): register the password recovery routes in the v5 coverage inventory
The inventory moved from v4 to v5 on main; the entry added by this branch
followed it.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(usage): distinguish real edits and template delivery with v5 consent
* fix(usage): exclude queued test messages and count reorders as edits
- queueEmail carries usageEligible: false into email_data and the queue
processor passes it on, so the dev tools' send-test-email no longer
records email_template_delivery once the worker sends it.
- event-types/reorder and categories/reorder-global compare the persisted
order before and after and record the v5 edit markers only when it
changed, matching the display_order edit already counted on PUT.
- normalized() builds arrays with Array.from so a row array from the sqlite
binding compares equal under Jest's separate realm.
* fix(usage): cover per-gallery category order and workflow test runs
- categories/reorder records category_editing when an event's override
changes; reorder/:eventId records it when an override was actually
removed.
- send_email and the collections handoff pass usageEligible: false for a
workflow test run (engine.testRun sets __test), so a non-dry test send is
not counted as template delivery.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(email): scrub gallery passwords from the sent-mail archive
The email queue kept every gallery password and client PIN in clear
text in email_data and rendered_html after the mail was sent, and the
Messages reading pane handed them back to any admin with the messaging
flag. A password hash in the events table bought nothing while the
plaintext sat next to it.
Once a mail is out, or its retries are exhausted, the processor now
masks secret-looking variables (password, passcode, pin) in email_data
and replaces their values in the rendered body, plain and HTML-escaped.
The reading pane applies the same masking to rows archived before this
change. Pending rows keep the real values so a retry still sends them.
Relates to issue 1271
* fix(email): keep a quoted ">" from cutting an attribute value out of redaction
The tag splitter stopped at the first ">", so a template attribute such as
title="{{gallery_password}} > details" left the password unmasked in the
archived HTML while email_data was already masked. The tokenizer is now
quote-aware; a tag with an unbalanced quote falls through as text and is
scrubbed there.
* fix(email): scrub secrets inside HTML comments in the archived body
A comment such as <!-- PIN: {{client_password}} --> was split off as a tag
and its body, which has no attribute, was never scrubbed. Comments are now
one segment and their content is masked whole.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(settings): expose the API rate limiter in the Security tab
The general per-IP limiter had six settings in app_settings and a
backend route to write them, and no screen. Installs ran on the code
fallback — 300 requests per 15 minutes per IP — with no way to see it,
which is how issue 1287 played out: a 546-photo gallery exhausted the
budget for one viewer and the operator learned about the setting from
a grep of the backend log.
Security tab: a card with the six settings, the validation ranges the
route enforces, a one-line explanation per field, and a note that the
unit is the client IP — an office or household behind one NAT shares
a budget, and behind a proxy TRUST_PROXY has to cover the proxy or
every visitor shares its address. The tab's Save button saves the
limiter through its own route. The limiter values are checked against
the route's ranges before anything is written and the limiter is
written first, so a rejected value cannot leave the password/session
settings half-saved behind a failure toast.
Backend, three things the screen needed:
- The settings read fills the six keys with the code defaults when
they have no row, so the form shows the budget in force rather than
an empty field; the defaults live in one exported constant the
limiter itself reads.
- The write route upserts instead of updating: on a fresh install,
which has no rows, the old UPDATE matched nothing and the route
answered 200 while changing nothing.
- The live limiter instances move into rateLimitService and the
write route rebuilds them. express-rate-limit fixes windowMs when an
instance is built — max and skip re-read the settings per request,
the window does not — so a saved window used to apply only after a
restart. The gates in server.js resolve the instance per request
through the service's getters. A rebuild starts fresh counters,
which on a settings change is acceptable. The limiters get explicit
MemoryStores and a rebuild shuts the superseded ones down, because a
store keeps a cleanup interval alive for as long as it exists and
dropping the reference alone would leak one timer per save.
Tests: the read surfaces defaults and honours the key filter; the
write creates rows on a fresh database, the limiter sees the values
immediately and hands the gates a fresh instance; existing rows are
updated not duplicated; out-of-range values are rejected. The tab
renders the values, edits through the hook state, carries the ranges,
saves with the tab's button, and the pre-write validation accepts the
bounds and rejects outside them and cleared fields.
Relates to issue 1337
* docs(security): point the rate limiter doc at the Security tab and the upsert
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* docs(security): correct the rate limiter defaults and how they are set
SECURITY_LOGGING.md said rate_limit_max_requests defaults to 1000 and
that the settings are configurable via the admin panel. The code
fallback when no app_settings row exists is 300 (19e125d8), a fresh
install has no row, and there is no admin screen: the settings are
written by PUT /api/admin/settings/security/rate-limit, which nothing
in the frontend calls. The reporter of issue 1287 ran on the 300
default without any way to see it.
The table now carries the real defaults, what the auth budget counts,
the exemptions including the gallery-image one from v3.127.0-beta.0,
and the TRUST_PROXY and shared-NAT caveats.
Relates to issue 1287
* docs(security): note the rate-limit route only updates existing rows
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
In Safari the product-usage consent dialog opened as a 302px box on a
714px viewport with the disclosure text squeezed into a 32px strip
between header and footer. The dialog is a native <dialog> laid out as
a flex column with only a max-height, so its own height is indefinite,
and the disclosure region used `flex-1`, which is `flex: 1 1 0%`.
WebKit resolves that 0% basis against the indefinite container height
as zero: the region's hypothetical size is zero, the dialog sizes to
header plus footer, and the max-height never comes into play. Chromium
treats the same basis as `content` and was fine.
An `auto` basis with min-height 0 sizes the region from its content and
lets it shrink to the max-height: measured in iOS Safari, 641px dialog
and a 371px scrolling region, footer on screen, matching Chromium.
Only the <dialog> is affected. The div-based modals that use the same
`flex-1 overflow-y-auto` pattern inside a max-height column were
measured in the same WebKit and size correctly, so they stay as they
are. A test pins the classes with the reasoning, since jsdom cannot
see the layout.
The block mixed Sie and du (four strings duzed, the rest siezte), used
two words for the same credential (Lookup-Hash, Abfrage-Hash) and two
for the same people (Maintainer, Betreuer), and carried a handful of
strings that read like a translation rather than German: "Die
Übertragung benötigt Aufmerksamkeit", "rundheraus abgelehnt",
"Integriert bedeutet verfügbar, nicht genutzt", "feste Galerie-Layouts"
for controlled layouts, "Erneut versuchen / fälligen Bericht senden" as
a button label.
Now du throughout (the form the maintainer chose for this area),
Abfrage-Hash and Betreuer everywhere, Collector left as the product
name it is, and the clunky strings reworded. No key added or removed;
meaning unchanged, so nothing here touches consent.
The "open usage portal" button was a plain link, so an operator who
wanted to see their own data had to copy the lookup hash out of the
settings page and paste it into the portal. Next to it sat a second
control, "connect to requests & voting", which minted a collector
session and then showed a third thing, a link to open it.
One button now. Before participation it stays the plain link: the
portal is public and someone deciding whether to join should be able
to look at it first. While participating, a click asks the backend for
a collector session (a signed `session` command, so the collector knows
which installation this is) and opens the portal with that token in the
URL fragment. Fragments are never sent over the wire; the portal drops
it from the address bar on load and keeps the session in memory only.
The lookup hash itself never leaves the settings page, and no URL a
server or an access log sees ever carries a credential.
The tab is opened synchronously in the click handler and navigated once
the session exists, because opening it after the await trips popup
blockers. If the collector cannot be reached the session command is
queued for retry and the tab falls back to the public portal, so the
click still lands somewhere; a failed request closes the tab again. The
separate connect button and its session link are gone, and so are
their strings.
The only way into the usage portal from the settings tab was the
session-bound link behind "Connect", which needs an active participation
and creates a 15-minute voting session. An operator deciding whether to
join had no way to look at the portal first. The status card now carries
a plain "Open usage portal" link to the collector base URL the status
endpoint already reports, shown whenever that URL is valid, opening in a
new tab with rel="noopener noreferrer". The session link stays as it is.
German "Teilnahme prüfen" read as a technical check rather than reviewing
the consent details; it is now "Details ansehen" in both places the key is
used (notice link and opt-in button). The actual opt-in stays
"Produktnutzung aktivieren".
Relates to issue 1317
features.v2/v3/v4.json carried every name, description and definition
twice, once in English and once in German, inside a file that is source
code, is vendored byte-identically into the collector and is served as
the consented catalog. Source stays English. The German strings already
existed a second time in the frontend locale file, which is what the
consent dialog actually renders (UsageCatalog reads
productUsage.catalog.<key>, never the JSON), so the copy in the catalog
was a duplicate that could only ever drift.
The `de` fields are gone from all three catalogs, their frontend copies
and the inventory definitions; the docs coverage file and
FEATURE_COVERAGE.md list English only. Nothing on the wire changes: the
report schema is derived from the keys, and the catalog's text is not
part of any signature or consent version string.
The coverage test now pins the catalog to the en locale verbatim and
requires the de locale to cover every key and field, without dictating
its wording. The collector holds the same catalog files and needs the
same change to stay byte-identical, plus its German catalog strings
moved into its own locale file; that is filed there.
Trivy flags the backend image on two sanitize-html advisories, both
fixed upstream:
- CVE-2026-63670 (fixed 2.17.6): a literal solidus after a raw-text end
tag (`</textarea/>`) is treated as text by htmlparser2 and re-emitted
unescaped, so disallowed markup passes when textarea or xmp is in
allowedTags.
- CVE-2026-84371 (fixed 2.17.7): an SVG SMIL animation whose
attributeName selects href lets the sibling values/from/to/by
attributes carry URLs past the scheme policy.
2.17.5 -> 2.17.7, exact pin as before. The new version brings its own
htmlparser2 12 / domhandler 6 / domutils 4 / dom-serializer 3 /
entities 8 tree under node_modules/sanitize-html; nothing else in the
lock moves.
That tree is ESM-only, so the backend now needs unflagged require(esm):
Node 20.19+ or 22.12+. The image is node:22-alpine and CI runs 22, but
engines.node still admitted 22.0-22.11, where require('sanitize-html')
throws ERR_REQUIRE_ESM at startup (publicSiteService loads it during
initialisation). engines is now ^20.19.0 || >=22.12.0 and the native
setup script's Node check enforces the same range instead of accepting
any 22.x. On the supported versions the sanitiser behaves identically
to 2.17.5 on the tracker and newsletter fixtures.
Jest 29's CommonJS registry cannot evaluate ESM either, so every suite
importing a route or service that uses the sanitiser would fail at
import. jest.config.js now maps `sanitize-html` to jest.sanitizeHtml.js,
which hands that one module to Node's real loader via
process.getBuiltinModule('module') — a plain require('module') inside
Jest is Jest's wrapper and returns an empty object for this package.
Verified against a real 2.17.7 install: the sanitiser suites and a
settings route suite pass; without the mapper they fail with "Cannot
use import statement outside a module".
The general per-IP limiter was inert until b0f33c17 registered it ahead
of the routers (budget 100, raised to 300 by 19e125d8), and 839bf4e4
then stopped gallery tokens from earning the authenticated skip. Since
that release every guest has been paying for the gallery's thumbnails
out of 300 requests per 15 minutes per IP. A 546-photo grid runs out
mid-scroll: the remaining tiles come back 429, which the frontend
turned into blank tiles with no error and no retry, and the next
refresh finds the photo list limited too.
Reproduced in iOS Safari against a seeded 546-photo Grid gallery:
loading in bursts, then nothing, no console output, no recovery — the
exact shape of the large-gallery report, whose plateaus were 125, 235,
300 and 308 tiles.
A token that verifies and names the gallery in the path is now exempt
on the image routes only: thumbnail, preview, hero and photo, GET only.
The photo list, downloads, feedback and every write stay on the budget,
a token for one gallery buys nothing on another, and the exemption
rides on skip_authenticated so an operator who turns the skip off
counts guests too. The 839bf4e4 concern — a free token as an unlimited
budget on every /api route — stays closed; what this hands back is the
bandwidth of routes a 300-request budget never bounded anyway.
Relates to issue 1287
Every tile, the hero and the folder covers switched to a <canvas> when
the per-event toggle was on or the protection level was `maximum`. A
canvas pins a backing store of naturalWidth × naturalHeight × 4 bytes
that the browser is not allowed to evict, and iOS Safari has a hard
budget for canvas memory that fails silently when exceeded — blank
tiles, no error, on exactly the browser the large-gallery report came
from. A gallery is several hundred tiles and one lightbox image.
What canvas buys on a thumbnail is a slightly harder right-click. What
actually protects the images is server-side: the served file is
watermarked and the download route refuses when downloads are off. The
photographer who reported the large-gallery case, shipping to real
clients, said the same and turned the global toggle off once it was
about to reach their next gallery.
So: tiles, hero and folder covers always render <img>. The lightbox
keeps both the per-event toggle and the `maximum` implication — one
image, where the calculus is different. The toggle is now wired to the
lightbox for the first time; before this it reached only the tiles, so
the label that said "canvas rendering" turned every grid into canvases
and left the lightbox alone. Labels in all four locales now say where it
applies.
`protectionLevel` was destructured in seven tile components only to feed
that OR; those props and their pass-throughs go with it. The shared
layout props keep it, since the story layout still hands it to its
lightbox.
A source-level test pins that only PhotoLightbox passes
useCanvasRendering to AuthenticatedImage or turns it on for `maximum`.
Relates to issue 1287
Current Umami script.js exposes `window.umami = { track, identify }`;
`trackView` was the v1 API. `trackPageView()` called it unguarded, so
every admin route change threw
`TypeError: window.umami.trackView is not a function`.
`trackPageView()` now prefers `track(fn)` with the sanitized URL merged
into the tracker's default payload (no `name` = page view), falls back to
`trackView` only on a legacy script, and no-ops when the script has not
loaded yet or offers neither. The call is wrapped so a throwing tracker
can never break navigation. The `window.umami` typing marks the legacy
methods optional so the compiler enforces the guard.
Relates to issue 1316
gallery_downloads.configured was true on every installation with a
gallery. allow_downloads ships true — column default in migration 037
and the create route both set it — and the snapshot asked "at least one
gallery has it on". The fleet value was ~100% by construction and could
not separate a deliberate configuration from an untouched one.
v2 consented to that key under that description, so v2 keeps sending it
unchanged. v3 replaces it with gallery_downloads_restricted: at least
one gallery has downloads switched off, which is the only state of that
column anyone actually decides. Same catalog position, so the disclosed
capability count stays at 86; the frontend copy, the EN/DE catalog
strings, the coverage inventory and FEATURE_COVERAGE.md follow.
Done in v3 rather than a v4 because v3 is on main and in no release
yet, so nobody has consented to it. The collector carries the same
catalog and has to take this change before the release that ships v3.
One guard for the window in which :main / :beta images already carried
the old v3 catalog. A report queued under it fails local validation on
this build, and deliver() left a locally invalid report pending for
good, blocking every operation behind it. A report's payload is derived
state, so deliver() now rebuilds it from the current snapshot in place
and sends that. Packet ID and sequence are kept — a re-signed retry has
to reuse them so a lost acknowledgement does not duplicate data — and
reports only: a stale registration, deletion or command is a genuine
conflict and keeps the existing handling.
Tests: the v3 snapshot counts a switched-off gallery and ignores
enabled ones, v2 still reports the old key with the old meaning, and a
stale queued report goes out rebuilt under the same packet id while a
valid one is sent untouched.
Relates to issue 1308
A rejected fetch in AuthenticatedImage set the error state, rendered
nothing, and never asked again. The fetch effect only re-runs when its
inputs change, and for a grid tile they never do — so a transient
failure (a hiccup on cellular, or Safari cancelling loads when the tab
goes to the background) was a permanently blank tile with no request in
flight and nothing in any log. Grid passes no fallbackSrc, so there was
not even a broken-image icon to point at.
The retry is bounded and gated. Three attempts with a doubling delay
(2 s, 4 s, 8 s), and an attempt fires only once the placeholder
intersects the viewport and the document is visible, so a tile that
failed while the user was away retries when they come back rather than
while they are still gone. A new src gets a fresh budget. The
fallbackSrc path is untouched: it already renders a plain <img> and
should not loop.
Two refinements from review. A final 4xx (anything but 408 and 429)
exhausts the budget at once: an expired gallery token or a missing
photo cannot be retried into existence, and on a 68-tile viewport three
retries each would be ~200 requests that cannot succeed. And a 429's
Retry-After is honoured as the minimum delay, because the backoff alone
would spend every retry inside a 15-minute rate-limit window and leave
the tile blank after the limit had lifted. Retry-After is not
CORS-safelisted, so server.js now exposes it for split-origin
deployments alongside Content-Disposition.
The error branch now renders the same grey box as the loading state
instead of null. That is what the retry effect observes, and it is
also something the user can see. The empty-src branch now clears the
error flag too, so a tile whose src is removed after a failure does not
keep showing the failure box.
Nine tests in AuthenticatedImage.retry.test.tsx; the retry cases fail
against the previous version.
Not presented as the fix for the iOS report. It closes the one gap that
turns a transient failure into a permanent one, which the reporter asked
for in the original issue, and it is worth having on any device.
Relates to issue 1287
The pre-load band made tiles arrive in time. It did nothing about them never
leaving. PhotoCard latched its observer with triggerOnce, so a tile that had
been scrolled past stayed mounted for the life of the page — holding its
object URL, and where image protection is on a canvas sized to the image that
the browser is not permitted to evict.
Measured in Chrome on a seeded 546-photo grid, scrolling top to bottom:
mounted tiles climb 24 → 100 → 212 → 364 → 546 and never fall. That is a
monotonically growing retained set, which is the profile a memory-constrained
browser discards a tab over — the reported symptom on iOS Safari 18.1 being
tiles that stop appearing and a blank page after refresh. With this change the
same scroll peaks at 68.
PhotoCard now takes an optional outer band. The inner band, unchanged, decides
when a tile starts loading; the outer one decides when it is far enough away
to unmount, and unmounting is what actually frees anything, because
AuthenticatedImage revokes its object URL and drops the canvas in its cleanup.
The gap between the bands is the hysteresis: at three viewport heights against
a one-viewport load band, a tile travels two further viewport heights after it
stops loading before it is released, so ordinary scrolling never crosses both
edges. Thumbnails are served private, max-age=1800, so returning costs a cache
hit rather than a round trip.
Opt-in per layout, and only Grid opts in. Its skeleton is aspect-square and
holds the tile's box exactly, so releasing shifts nothing; the measured
layouts have no such guarantee. Without the prop the observer keeps its
original latch, so every other layout behaves exactly as before — pinned by a
test, since that is the half most easily broken by accident.
This is not presented as the fix for the iOS report. It removes the mechanism
that best explains it, and it is worth having on any device; whether it is the
mechanism still needs a measurement from the phone that failed.
Relates to issue 1287
Probing the live collector to settle the delete-sequence question turned up
something else: usage.picpeak.app answers a valid usage.v2 registration with
INVALID_PACKET while the identical v1 flow is accepted. It does not speak v2
yet — which the deployment notes already require, but the consequence of
getting that order wrong was worse than "reports do not send".
Opting in to v2 against a v1-only collector left the installation stuck.
Registration was refused, so nothing existed at the collector at all; the row
sat in activation_pending, disable moved it to deletion_pending, retry was
futile forever, and enable refused because the row was not `disabled`. The
abandon hatch added earlier did not apply: it was gated on
SIGNING_KEY_UNREADABLE. So the most harmless possible failure — nothing
registered anywhere — was the one an operator could not clear.
The gate is now the property that actually matters: a participation the
collector has provably never accepted (sequence 0, no receipt) with a failing
delivery can be discarded, from activation_pending as well as
deletion_pending. Its receipt records `never-registered` rather than an
unconfirmed deletion, because nothing remote exists to be unsure about. A
participation the collector *did* accept keeps the old narrow gate and its
explicit warning — clearing local state while the collector still holds
reports must stay a deliberate, warned-about act.
A collector that rejects a registration or a deletion outright now reports
SCHEMA_NOT_ACCEPTED instead of DELIVERY_FAILED, and the settings page says the
collector does not accept this report version yet. Retrying cannot fix that,
and sending the operator to look for a network fault they do not have was
wrong.
Verified end to end against the live collector: v2 opt-in reports
SCHEMA_NOT_ACCEPTED, the exit is offered immediately, the receipt says
never-registered, and joining again on v1 registers, reports and withdraws
with a collector-confirmed deletion.
The dialog described what is sent and where it goes, but never said which way
the connection runs. That is the part an operator is actually being asked to
accept: opening an outbound path to someone else's service.
PicPeak sends and never pulls. One place in the service reaches the network,
it is a POST, and it requests exactly two paths — /api/envelopes, and
/api/participant/lookup only when an operator asks for their own export. No
scheduled job contacts the collector; the daily rollup is driven solely by an
authenticated admin hitting /activity. There is no route the collector could
call, and redirect: 'error' means it cannot even point a request somewhere
else. From a reply only the acknowledgement for the packet just sent is read,
with every field compared against that packet before it is accepted; the
stored copy drops the session token and no read path hands it back to the UI.
A requested export is streamed to the operator as a file and never
interpreted.
The consequence is why it belongs in the consent text and not only in the
docs: this channel cannot deliver code, configuration or content into an
installation, not even from a collector that has been taken over. It is a
security property by design rather than by convention.
usageOutboundOnly.test.js guards it by source inspection rather than
behaviour, because a behavioural test only proves that today's calls behave.
It fails the moment someone adds a second fetch, a poll for messages, a
scheduled pull, or a public route touching the usage service — verified by
injecting each of those.
A QA exploration of this branch against an isolated rig — own stub
collector, SQLite and PostgreSQL — turned up one dead end and a set of
signals and controls that did not hold up. This closes all of them.
Rotating JWT_SECRET, the documented response to a suspected compromise,
made the signing key unreadable. That was already named and documented,
but it left no way out: the delete packet can never be signed, so the
row stays deletion_pending forever, and enable() refuses because it is
not `disabled`. An operator who rotated precisely because the secret was
compromised cannot restore it, so the feature was bricked with no
control left. POST /usage/abandon is offered only in that state; it
drops the local identity and records the receipt as
`collector-unconfirmed` rather than claiming a deletion that did not
happen.
Every failed delivery was retried on the next admin request, and
/activity is open to any authenticated admin while the settings ticker
fires it every five minutes per open tab — 30 activity calls against a
rejecting collector produced 30 outbound requests. Migration 206 adds
attempts/next_attempt_at and the unattended sender honours the gate;
Retry and opt-out still send immediately, and the tab names the time of
the next automatic attempt.
Feedback, votes and portal sessions now share an installation-wide
budget of 30/hour. They are the only endpoints whose effect is outbound
traffic carrying operator-written free text, and the general limiter
skips authenticated requests by design. Reading status and withdrawing
stay unthrottled.
gallery_image_protection was true on a bare install with no galleries:
PicPeak ships default_protection_level='standard' and
enable_devtools_protection=true, so it reported fleet-wide 100% and
could never separate a decision from an untouched default. It now reads
only what deviates from the shipped defaults, and the devtools flag is
not read at all — being on by default, its only informative state is
off, which is the opposite of what the key claims.
Also:
- the export receipt counted every packet and called the total "usage
reports"; reports and participant operations are now counted and named
separately
- GET /usage/preview no longer persists the custom_css marker, so the
transparency view stops changing what will be sent
- the feedback route requires every field the packet schema requires,
so an API caller gets the missing field named instead of a bare
INVALID_PACKET from inside signing
- the German strings for this feature use "Sie" throughout, matching the
rest of the admin UI; the ignore hint says what ignoring will do
rather than stating it as already true
- the consent dialog returns focus to the control that opened it
- the long buttons wrap instead of running off a 390px viewport
- a deletion receipt is labelled as belonging to an earlier
participation while a new one is active
Regression tests cover each of these, including the delete packet's
reuse of the last accepted sequence, which was an unwritten assumption
about the collector rather than a defect.