Second and final step of #2166. `react-intl` is replaced by
`app/common/intl.tsx`, a small i18n binding over Preact context, and with
`react-redux` already gone nothing holds the React compatibility alias.
React is now absent from the lockfile, the installed tree, the config and
the bundles: `react`, `react-dom`, `react-intl`, `@types/react`,
`@preact/compat`, `use-sync-external-store` and `intl-messageformat` are
all gone, along with the `paths` entries in `tsconfig.json` and three
babel-loader excludes. Runtime dependencies go from 15 to 10.
`preact/compat` goes too, which matters more than its 3.8 kB. Importing
it anywhere installs hooks on preact's shared `options` that remap
`onFocus`/`onBlur` to `focusin`/`focusout` for every element and make
`@testing-library/preact` rewrite `change` to `input`, the two bugs
behind #2166, still live until now. `Button` was wrapped in `forwardRef`
with no caller passing one, and `TextareaAutosize` now takes its ref as
an ordinary prop. The workaround in `sort-picker.spec.tsx` is gone with
them, since `fireEvent.change` reaches a `<select>` again.
Gzipped, against master: `remark.mjs` 76.17 kB to 56.47, `last-comments.mjs`
37.97 to 18.26, `deleteme.mjs` 14.51 to 8.42. The limits move with them and
keep more relative headroom than master shipped.
### The binding
`IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,
`FormattedMessage` and `IntlShape`. 32 files change only their import.
The export names copy react-intl's deliberately: `formatjs extract` finds
messages by recognising `defineMessages`, `FormattedMessage` and
`intl.formatMessage` in the AST rather than by import source, so renaming
one silently empties the catalogue. `frontend/CLAUDE.md` records that,
along with the destructive part: `translation:generate` would then strip
the unextracted keys from all 24 catalogues and the check would pass.
A message the binding cannot parse falls back to the message in the
source: a broken, unhandled or nested tag, a brace that is not a
well-formed placeholder, and a placeholder naming a value the caller did
not supply. `mk.json` and `th.json` carried broken markup and rendered in
English; both are repaired, so a catalogue sweep over every locale can now
require well-formed markup with no exceptions listed.
`translation:check` gained the validation that would have caught them when
they were proposed: a translation's tags have to be well-formed pairs of the
names the English string uses, with no attributes, and its placeholders have
to be ones the English string provides. Leaving a tag or a placeholder out
stays allowed. Run against master's catalogues it reports both.
### enzyme
`@types/enzyme` was the last thing pulling `@types/react`, so React could
not leave while enzyme stayed. Its three test files move to
`@testing-library/preact`, which now has no rival: `@testing-library/preact-hooks`
had one import left and its own unmet peer warning. `intersection-observer`
was a runtime dependency nothing imported, and the `cheerio` override lost
its last dependent with enzyme.
Enzyme's `.find(X).prop()` threw unless exactly one node matched, so the
converted tests assert node counts explicitly to keep that.
### Verified
All 181 message ids formatted across all 24 catalogues through both real
react-intl and this binding: 4344 comparisons, no differences. From a wiped
`node_modules`: `pnpm install --frozen-lockfile`, `pnpm lint`,
`pnpm type-check`, `pnpm test` (392 tests, 42 suites), `pnpm build`,
`pnpm size-check`, `pnpm translation-check`.
* Replace react-redux with a preact context binding
One of the two packages holding the @preact/compat alias in place, and
the contained one: the store is plain redux, and the only react-redux
import inside it was a single line re-exporting typed hooks.
* Drop the now-unused react-redux types
* Subscribe before paint and check once on subscribe
Previously, useSelector subscribed to the store inside useEffect, which
runs after paint. A dispatch landing between render and that effect was
never delivered, since the listener did not exist yet, so the component
kept rendering a stale value until some later unrelated dispatch happened
to differ from the stale ref.
Subscribing in useLayoutEffect narrows the window to before paint, and
running the check once immediately on subscribe closes it, which is what
react-redux does for the same reason.
Adds the first tests for the binding, one of which fails without this
change: the store holds 1 while the DOM still shows 0.
* Only re-check on subscribe when the state actually moved
The subscribe-time check ran unconditionally, so it re-ran the selector
at mount. A selector building a fresh object fails Object.is against the
value the render already computed, which forced a second render of every
connected component: ConnectedRoot and every ConnectedComment, so around
201 extra renders for a 200-comment thread.
Reducers return a new root object on every change, so an unchanged state
reference means no dispatch was missed and the check has nothing to find.
Comparing against the state the render used keeps the property the check
exists for while dropping the extra render.
The race test still exercises the guarded path, since its dispatch
produces a new state object, and a new test pins the mount case: it fails
without the guard.
Raised by umputun in review.
* Require node 20 and record every place the version is pinned
The declared floor was >=18 while CI, Docker and both .nvmrc files had
been on 20 since the pnpm 8 to 10 migration, and transitive dependencies
now require 20.18.1. The docs had drifted further still, telling
contributors to install Node 16 and PNPM 8.
* Set the node floor to the strictest dependency and keep one checklist
undici needs >=20.18.1, so a bare >=20 advertised support for 20.0 to
20.18.0, which fail dependency engine checks. frontend/CLAUDE.md already
carried a pinning checklist, so the new entries fold into it rather than
starting a rival list in the root file.
* Keep the node floor at the major, not a patch version
engines.node states the major we support. Individual dev dependencies
can be stricter within it, and chasing those patch floors into engines
and the docs would turn every lockfile refresh into a docs change.
* Update preact to 10.29.8
Also moves TypeScript to 5.9, which preact 10.29 typings require, and the
compat and testing library pins that go with it. Type checking resolves
JSX from preact via the automatic runtime; the bundle keeps the classic
transform so babel still strips test ids.
* Move babel to the automatic JSX runtime and refresh frontend notes
Leaving babel on the classic h pragma while tsconfig used the automatic
runtime meant a tsx file without an h import would type-check and lint
clean, then throw at runtime, since eslint-config-preact disables
react/react-in-jsx-scope and no-undef is off.
* Address review findings on the preact upgrade
Forward the textarea ref with useImperativeHandle so it clears on unmount
and lands during commit rather than after paint. Pair typescript-eslint
with the TypeScript it now has to parse. Use the preact namespace types
rather than the deprecated JSX aliases, and drop the redundant type
re-declarations the element-specific interfaces already provide.
* Drive the focus tests through real DOM focus and blur
Dispatching a synthetic focusin hard-coded preact/compat's internal
alias for onFocus. Calling focus() and blur() exercises the sequence a
browser produces and stays correct if that mapping changes.
* Raise the two bundle limits the preact upgrade pushes past
CI measures remark.mjs at 78024 bytes against a limit size-limit reads as
78000, so it failed by 24. last-comments.mjs had 36 bytes of headroom and
would have tripped on the next change.
* Regenerate the lockfile after the rebase
The rebase resolution left it missing the @typescript-eslint entries, so
every CI job failed at pnpm install --frozen-lockfile.
frontend/* required @akellbl4 or @Mavrin, so umputun could not satisfy the
code-owner rule on any frontend pull request. #2172 needed an admin override
and #2163 could not use one, because GitHub routes stacked pull requests
through the async merge endpoint, which applies no override.
The path filter matched only frontend/apps/remark42/**, so a change to the
workspace root ran nothing: no lint, type-check, tests or size-limit, and no
docker build either since docker.yml waits on this workflow by name.
#2160 rewrote pnpm-lock.yaml and the override block, and #2172 removed a
workspace package and its CI workflow. Neither ran a single frontend check on
its PR or on master.
* Drop the @remark42/api package
It cannot authenticate anyone: clients/auth.ts exposes only anonymous,
email and telegram, with no OAuth method, and the fetcher never sets
credentials so its cookie auth cannot work cross-origin. Nothing in the
repo consumes it, no third-party consumer exists, and npm has served an
alpha from July 2022 that CI never publishes.
* Drop the removed workflow from the pnpm pinning checklist
frontend/CLAUDE.md still counted ci-frontend-api.yml among the places the
pnpm version is pinned, and stated a fixed total that no longer holds.
All 23 open Dependabot alerts against frontend/pnpm-lock.yaml resolve to
packages whose override floor sat below the patched release. Every floor
now carries an explicit upper bound, as an open-ended floor lets pnpm
resolve across a major version.
* feat: add configurable SMTP HELO hostname
Allow the SMTP HELO/EHLO hostname to be configured separately from
the SMTP server hostname.
This is useful when the SMTP server requires clients to identify
themselves with a fully qualified hostname different from the server
address.
* chore: remove vendored dependency changes
* Bump go-pkgz/notify to v1.4.0 and document SMTP_HELO_HOST
The HELOHost field lands in go-pkgz/notify v1.4.0, so the branch needs the
bump to compile; v1.3.0 in master has no such field. The example module is
tidied alongside, as any change to backend/go.mod requires.
Documents the parameter in the parameters table and, separately, in the email
setup page: what it does, that leaving it unset keeps the previous `localhost`
greeting, and the case it exists for, a relay refusing the greeting under
Postfix `reject_non_fqdn_helo_hostname`.
Also records the current limit: verification emails for email authentication
go through go-pkgz/auth's own sender, which has no equivalent setting, so the
greeting there is unchanged.
* Bump go-pkgz/auth to v2.2.0 and apply SMTP_HELO_HOST to verification email
The verification email sender had no way to set the greeting, so a relay that
refuses the HELO would accept notifications and still reject sign-in emails.
EmailParams gains HELOHost in go-pkgz/auth v2.2.0, so the same SMTP_HELO_HOST
now drives both paths.
The example module is tidied alongside, as any change to backend/go.mod
requires.
---------
Co-authored-by: oli <someone@somewhere.tld>
Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
rest.CORS refuses "*" together with credentials since go-pkgz/rest#52, so the
bump and the option have to land together: the option does not exist in v1.22.0
and the panic fires at construction, inside routes(), which makes it a startup
failure rather than a request-time one.
The wildcard stays. The comment widget is embedded on arbitrary third-party
sites, so the set of origins is not knowable, which is why the escape hatch was
asked for upstream instead of accepting the panic. What it costs is unchanged
and now written next to the call: any site a signed-in user visits can read
authenticated responses, so state-changing requests have to keep being protected
by something other than the origin, X-XSRF-Token today.
The example module is tidied in the same commit, as it reaches go-pkgz/rest
through the replace directive and its indirect graph would otherwise keep the
old pin and fail the readonly module check in CI.
The bump also carries testify to v1.12.0, which drops go-spew and go-difflib
from the module graph.
govulncheck fails on master against the 1.25.12 pin with seven stdlib
advisories, all fixed in 1.25.13: GO-2026-5026, GO-2026-5972, GO-2026-6088,
GO-2026-6089, GO-2026-6090, GO-2026-6091 and GO-2026-6218, across crypto/tls,
encoding/asn1, encoding/xml, html/template, net/http and net/url.
Pinning the next patch would only move the problem to the following advisory,
as it did in 76d0cc2c. setup-go accepts a minor-only spec, so "1.25" resolves
to a patch on that line at run time. Staying on 1.25 rather than "stable"
keeps a move to a new minor a deliberate change, matching the `go 1.25.0`
directive in both go.mod files.
check-latest is required with it: by default setup-go uses the patch already
cached on the runner image, so a minor-only spec alone would keep resolving
to whatever that image ships, currently 1.25.12, and the scan would stay red.
Previously the rule was scoped to "updating Go modules", which reads as
version bumps only. Adding or removing a dependency, or changing the `go`
directive, puts the example module out of step in exactly the same way, and
the failure is the same `test examples` step reporting "updates to go.mod
needed".
Also records that Dependabot Go module PRs need this, since the bot updates
`backend/` alone.
Excessive memory allocation during VP8L decoding, reachable from
app/store/image/image.go:333 where image.Decode runs on uploaded data.
The existing DecodeConfig dimension guard doesn't cover it, since the
over-allocation happens during decode rather than from declared dimensions.
Tidies backend/_example/memory_store in the same commit: it carries the
backend's deps as indirect entries and would otherwise fail the example CI step.
Previously the widget document had `padding: 6px` on the body, so every
embedded widget sat 6px inside its container and could not align flush with
the host layout. `updateIframeHeight` then reported
`document.body.offsetHeight + 12`, but the body is `box-sizing: border-box`
and `offsetHeight` already includes padding, so the addition double-counted
it.
Measured against the deployed widget: the content needs 20610px, the body
reported 20622px with the padding, and the parent was told 20634px, leaving
24px of empty space below every embed on top of the horizontal inset.
Removing the padding does not clip anything. With it at zero, offsetHeight,
body scrollHeight and documentElement scrollHeight all agree, and the last
child carries no bottom margin, so no margin collapses through the body edge.
Resolves#1487.
Previously the pages Remark42 serves from /web/ carried a build-time host.
The `{% REMARK_URL %}` placeholder is substituted during the image and
release-asset builds, both of which write `http://127.0.0.1:8080`. Docker
rewrites it again at container start from REMARK_URL, but a release binary
has no equivalent step, so it serves demo, counter, last-comments and
deleteme pages pointing at the visitor's own loopback address. `counter.ejs`
additionally had that address hardcoded in two "note" links, which no
substitution touched.
These pages are served by Remark42 itself, so the host is whatever origin and
path prefix delivered them. Deriving it from `location` is correct at the root
and under a path prefix alike, and needs no build-time value. Sibling links
are now relative for the same reason.
`site_id` is left as a literal so the startup substitution in docker-init.sh
keeps matching it.
Reported by @andreas-hempel.
Resolves#1996.
Previously any message reaching the widget closed the Sign In dropdown,
because the handler returned early only for a clickOutside payload while
closing was disabled and fell through to closing in every other case. The
embedding page posts hash, title and theme messages of its own, and
`embed.ts` installs a MutationObserver on the host page title that posts on
every mutation, so a host page whose title changes closes an open login form
and discards whatever was typed into it. Browser extensions that post into
the page have the same effect.
After this change the dropdown closes only for a clickOutside payload from
`window.parent`. Reproduced against the deployed demo: with the form open and
filled, a single `document.title` assignment on the host page removed it,
while three seconds of inactivity did not.
Resolves#2139.
Previously the npm entries carried only open-pull-requests-limit: 0, which
bounds version updates and leaves security updates unlimited, so npm pull
requests kept arriving from Dependabot alerts. The ignore option applies to
both kinds, so a blanket ignore per npm entry is what actually stops them.
Go modules and GitHub Actions updates are unchanged.
Previously the Microsoft setup instructions did not mention supported
account types. Remark42 authenticates against the `common` endpoint by
default, which only accepts an application registered for both work or
school accounts and personal Microsoft accounts, so an application created
with any other value fails to authenticate with no hint as to why.
The reproxy manual still configured `AUTH_TWITTER_CID` and
`AUTH_TWITTER_CSEC` in its example, which have been deprecated and
non-functional since 1.14.0.
Resolves#1823.
site/** pull requests get no build validation: ci-site.yml declares a
pull_request trigger but gates its only build job to master and tags, so
a bad site lockfile first fails on the post-merge run that deploys.
frontend pnpm override floors still admit js-yaml 3.15.0 and 5.2.0,
leaving three open advisories including the one PR 2141 closed for site/.
alterComment issued two engine.Flag calls (Blocked, then Verified) for
every comment, so a listing of N comments triggered up to 2N BoltDB read
transactions even when many comments shared the same author. Find,
FindSince, User and Last all funnel through it.
Add a userFlagCache that memoises blocked/verified results by site and
user for the duration of a single listing, so repeated authors are
looked up once. alterComment keeps its signature for single-comment
callers (Get) by using a fresh cache; the batch paths share one.
#2122 collapsed the per-registry builds into one build with two type=image
outputs and a single steps.build.outputs.digest. With build-push-action's
default provenance attestation, that digest does not resolve at ghcr, so the
multi-arch manifest step fails ("ghcr.io/...@sha256:...: not found"). Restore
the separate build-ghcr / build-dockerhub steps so each registry gets its own
digest. The ci-build.yml type=gha cache change from #2122 is kept.
exportCtrl mapped every export failure to 500 Internal Server Error, so
requesting a backup for a non-existent site (e.g. wrong -s/--site) came
back as a misleading 500 instead of a client error — inconsistent with
the rest of the admin/public API, which returns 400 + ErrSiteNotFound
for site-lookup failures.
Add an engine.ErrSiteNotFound sentinel (wrapped at the bolt db-lookup so
the existing "site %q not found" message is unchanged) and map it to 400
+ rest.ErrSiteNotFound in exportCtrl; genuine internal failures (gzip
close/write) still return 500.
Milestones: one vX.Y.Z per release; decide a PR's release by whether its merge
commit is contained in a release tag (git tag --contains), not by dates; issues
get a milestone only when closed by a code change. Plus the issue-label taxonomy
(type / area / priority / contribution / resolution).
setup-go with go-version "1.25" resolved to 1.25.11, which govulncheck flags for
GO-2026-5856 (ECH privacy leak in crypto/tls, fixed in go1.25.12). Pin the exact
patch in ci-backend.yml and release.yml so the vuln scan passes and release
binaries build on the fixed toolchain.
ci-build.yml used the legacy actions/cache + /tmp/.buildx-cache local
cache with a manual rotate step. Switch it to buildx type=gha cache
(separate scopes for the main and example images), dropping the
actions/cache and rotate-cache steps.
docker.yml built each image twice per platform — one build-push-action
call per registry. Build once and push the same content-addressed image
to both ghcr.io and DockerHub via multiple outputs; the single build
digest is identical for both registries, so digest export is simplified
accordingly. The multi-arch manifest merge is unchanged.
The published multi-arch Docker manifest is amd64+arm64 only, but
GoReleaser and the Makefile dockerx target still built linux/arm/v7
binaries with no matching image. Drop the armv7 target (goarch arm +
goarm 7) from .goreleaser.yml and linux/arm/v7 from the Makefile so
shipped binaries match the images; other platforms are unchanged.
Also remove the goconst and lll settings blocks from
backend/.golangci.yml — neither linter is in the enable list, so the
settings were inert.
TestService_WithDrops and TestService_SubmitVerificationWithDrops
submitted three items into a size-1 queue and asserted at least one was
dropped, relying on the single consumer not draining the queue between
submits. synctest does not fully pin this because the MockDest send path
does real logging I/O outside the bubble, so under CI's -race scheduling
the consumer occasionally drained all three, delivering everything and
failing the "<= 2" assertion (~0.2% of CI runs).
Add an optional gate channel to MockDest so a destination blocks in
Send/SendVerification until released. The tests now submit one item (the
consumer picks it up and blocks on the gate), fill the size-1 queue,
submit an overflow item that is dropped, then release the gate — the
drop is deterministic regardless of scheduling. Assert exactly two
delivered instead of "at most two".
Compose files were not covered by any CI workflow, so a malformed change
to docker-compose.yml or a compose-*.yml could merge unnoticed. Add a
workflow that runs docker compose config on every tracked compose file
(vendored ones excluded) on changes to any of them.
Nothing in CI guarded against known vulnerabilities in the Go
dependency tree. Add a vulncheck job that runs govulncheck over the
backend module on every backend change. The version is pinned rather
than tracking latest for reproducible runs. Current tree scans clean.
The test decided the expected HTTP status with strings.Contains(tc.params,
"=bad"), but comment IDs are random UUIDs. When one started with "bad"
(e.g. offset_id=bad49e60-...), the param string contained "=bad" and the
case was wrongly expected to return 400 while the handler correctly
returned 200, failing the run about 0.2% of the time.
Identify bad-request cases by their error response body instead, which
is deterministic per case and independent of the generated IDs.
The limit() boundary used >=, so a subtree that fit the page exactly was
treated as overflow and dropped — under-filling the final page (e.g.
limit=5 over subtrees 3,2 returned only the first subtree, 3 comments,
instead of both). Change it to > so an exact-fit subtree is included;
the first node is still always returned in full and larger subtrees
still overflow to the next page.
Adds table tests for MakeTree's limit/offset pagination and countReplies,
and updates the /find consistent-count expectations for the corrected
boundary.
The top-level version: "2" field is ignored by modern Docker Compose,
which warns about it on every invocation. Drop it from docker-compose.yml
and the compose-dev-backend, compose-dev-frontend and compose-e2e-test
files.
ci-backend.yml had pull_request: types: [opened, reopened], which
excludes synchronize, so pushes to an open PR branch did not re-run
backend tests, lint or coverage and a broken follow-up commit could land
after the first green run. Drop the types filter so all default
pull_request events trigger the workflow.
ci-frontend.yml and ci-frontend-api.yml now install with
pnpm install --frozen-lockfile instead of pnpm i, matching release.yml
and preventing silent lockfile drift in CI.
notify/email.go accumulated multi-recipient errors with
multierror.Append(fmt.Errorf(...)) instead of
multierror.Append(result, ...), so the accumulator was overwritten each
iteration and only the last failing recipient's error survived; earlier
failures were silently dropped. The telegram notifier did it correctly.
Replace hashicorp/go-multierror with the stdlib errors.Join everywhere
it was used (notify/email.go, notify/telegram.go, rest/api/rest_private.go,
store/service/service.go, store/image/image.go and store/engine/bolt.go),
which fixes the bug and drops the direct dependency. It stays indirect
because go-pkgz/lcw/v2 still imports it. A regression test in
email_test.go now sends two failing recipients and asserts both errors
are reported.
on dark host pages the widget flashed an opaque white rectangle while loading.
the iframe element carries color-scheme from the theme param, but its document
had none until remark.tsx ran, and a mismatched color-scheme makes the embedded
canvas opaque instead of transparent. broken since #2023 added the element-side
color-scheme to fix a firefox dark-mode bug.
set the document's color-scheme from the theme param in an inline head script,
before first paint, using the same rule as create-iframe.ts. that closes the long
window but not the surface browsers paint before the document is parsed, which
webkit renders white and chromium hides behind paint holding. so also create the
iframe hidden and reveal it when the document posts inited, with a timeout
fallback so a failed bootstrap cannot leave the widget invisible.
the reveal lives in createIframe rather than embed.ts so the profile modal, the
other caller, gets it too. that modal focuses its iframe on open, and a hidden
element cannot take focus, so focus now fires from the reveal instead of a timer.
covered by a unit test for the reveal paths and the event.source guard, and by
e2e for the document's color-scheme and the iframe's visibility before inited,
after inited, and after the fallback.
the docker workflow chained off the backend workflow only, and backend has a
backend/** path filter. master pushes touching just frontend/apps or the docker
files never triggered docker.yml, so no master image was published and
remark42.com was not redeployed. broken since the build workflow was split in
#1977.
listen to workflow_run from both backend and frontend, and add Dockerfile,
docker-init.sh and .dockerignore to the backend workflow paths to restore the
path coverage the old build workflow had.
On mount ConnectedRoot immediately reported the iframe height to the
parent page while the app was still showing the global preloader, so the
parent shrank the iframe from its initial size to ~63px and then grew it
back step by step as content rendered. On pages with many comments this
reads as the widget blinking several times before loading (reported for
radio-t.com). The June frontend dependency refresh (#2091) shifted
render/effect timing enough to make the premature measurement happen on
every load rather than only on slow connections.
Move the height reporting into Root and start it in the setState callback
that replaces the preloader with real content: the first height message
now always describes rendered content, the iframe never shrinks below it,
and subsequent ResizeObserver updates only grow the frame as comments
arrive. Also adds the previously missing observer disconnect on unmount.
Verified by instrumenting the embed with a height-message listener:
master sent 63px then 316px on an empty test page (v1.16.1 sent a single
316px); with this fix the first message is 316px again.
Follow-up to the review notes on #2116:
- warn at startup when --trusted-proxy contains a catch-all (0.0.0.0/0 or ::/0),
which trusts every peer and re-opens the bypass - mirrors the unset-case warning
- realIPMiddleware tests: cover the unparseable-peer and trusted-peer-without-header
branches, and make the observed values per-call so subtests don't share closure locals
- trim the flag description and shorten the startup warning to the terse [WARN] style
Rate limiting and (with --votes-ip) vote de-duplication key on the client IP,
recovered from forwarding headers (X-Real-IP / X-Forwarded-For / CF-Connecting-IP)
when behind a reverse proxy. Those headers were accepted from any client, so a
caller could set them to change its apparent IP.
Add --trusted-proxy / TRUSTED_PROXY (comma-separated CIDR/IP): forwarding headers
are honored only when the direct peer is a trusted proxy; other peers keep their
real socket address. Unset preserves the previous trust-all behavior (with a
startup warning) so existing deployments keep working on upgrade.
Docs: a 'Trusted proxies and client IP' section with per-topology guidance, plus a
note in the nginx manual.
Bumps go-pkgz/auth to v2.1.5, which adds avatar.ErrNotFound. deleteMeRequestCtrl's
avatar removal was best-effort (log and continue on any error) because before the
sentinel there was no portable way to tell an already-removed avatar from a genuine
failure. It now tolerates only errors.Is(err, avatar.ErrNotFound) - keeping the
repeated-request idempotency - and surfaces any other store failure as 500.
The delete_me token built by deleteMeCtrl omitted the user's Picture, so the
avatar-removal branch in deleteMeRequestCtrl never ran for real requests and
avatars survived account deletion. Carry Picture in the token so the stored
avatar is removed when the request is processed.
Make the removal best-effort: the avatar stores report an already-missing
avatar as an error with no distinguishable sentinel, and the user's data is
already deleted at that point, so a missing avatar (e.g. a repeated request)
no longer fails the whole deletion with a 400.
Only remove a well-formed avatar id ("<hash>.image") so a malformed picture
can't make a filesystem-backed store target an unexpected path.