Compare commits

...
118 Commits
Author SHA1 Message Date
Dmitry VerkhoturovandGitHub 3286f028e3 Document what each browser actually does with cross-domain auth (#2222)
Measured on real domains over real certificates, Remark42 on one registrable
domain and the host page on another, with a control cookie behind every
blocked column so a run that blocks nothing cannot report a pass.

Three results the manual did not carry. Safari blocks third-party cookies out
of the box, so AUTH_SAME_SITE=none on its own has already stopped working
there, which makes the old recipe broken today and not deprecated later.
Firefox reaches a working session by a weaker route than Chrome and Safari do:
it accepts the server's attribute-less cookie, and because that cookie is
HttpOnly the browser then forbids the widget's script from replacing it, so the
session rides on an ordinary unpartitioned third-party cookie even with the
header flag on. And Firefox's block-all setting discards partitioned cookies
too, so no configuration survives it.

Two parameter descriptions were wrong in ways that matter here. AUTH_SAME_SITE
default emits no SameSite attribute rather than Lax, which is precisely what
lets the widget's own cookie land on Chrome and Safari. And AUTH_TTL_COOKIE
does not govern the cookie that carries the session under the header flag,
since the frontend hardcodes 200h to mirror the default.
2026-08-23 18:03:49 -05:00
UmputunandGitHub c947a06d48 Release the response before tearing the test server down (#2212)
TestRest_securityHeaders and TestRest_frameAncestors both start a server, read
one response, and then call teardown() partway through the test to start a
second server with different options. The first response body is only closed by
a defer, which does not run until the test returns.

httptest.Server.Close waits on connections still in use, so it blocks on a body
that will not be closed until after it returns. The tests deadlock and the whole
rest/api package dies on the timeout rather than on an assertion.

CI pins go 1.25, where the responses are small enough that the connection goes
back to the pool on its own and nothing hangs. On go 1.27 both tests hang, which
is how this surfaced.

Close the body and the client's idle connections before teardown() in both.
2026-08-23 17:51:52 -05:00
UmputunandGitHub 5f439cf1d5 Qualify what works off-domain, and fix two typos beside it (#2221)
The opening summary said Telegram, Email and anonymous auth "would work
everywhere". That holds only with AUTH_SEND_JWT_HEADER set, and the widget's
own cookie is Secure, so the path is HTTPS-only and bounded by ALLOWED_HOSTS
besides. The sentence now states those conditions. What happens without the
flag is two separate things, whether sign-in succeeds in the frame and whether
it survives a reload, and the body below already separates them.

The other two are older: a stray backtick after "work on any domain", and
"expect" for "except" in a bullet whose neighbour already says except.

Related to #2218
2026-08-23 17:51:47 -05:00
Dmitry VerkhoturovandGitHub 7de51ad2ef Document what actually keeps a cross-domain reader signed in (#2218)
* Document what actually keeps a cross-domain reader signed in

The separate-domain manual tells operators to set ALLOWED_HOSTS and
AUTH_SAME_SITE and says authorisation then works anywhere. That stopped being
true as browsers began blocking third-party cookies: the server-set auth
cookies carry no Partitioned attribute, so a browser enforcing the block drops
them whatever their SameSite value. What survives is AUTH_SEND_JWT_HEADER,
where the token returns in a header and the widget writes its own partitioned
cookie from inside the frame, and the manual never mentioned it. It now does,
with the XSS trade-off and a pointer to the parameter page, and it says plainly
that this rescues Email, Telegram and anonymous but not oAuth.

The parameter page's own mitigation list was left wrong by #2197. It promised
SameSite=Strict cookies and a __Host- prefix on HTTPS; authCookieOptions drops
the prefix entirely and uses SameSite=None; Secure; Partitioned whenever the
widget is embedded on another domain, which is the case the flag exists for.

* Say that the JWT header is sent in addition to the cookies, not instead

Both the flag's own help and the parameter table said the header replaces the
server-set cookie. Service.Set does neither: it writes the header and then
falls through to set both cookies, with a comment saying the cookies are needed
because headers do not survive the OAuth redirect. An operator reading either
description would expect the server to stop setting cookies once the flag is
on, and would misjudge what the flag changes about their exposure.

* Correct three details in the cross-domain documentation

The link to the parameter page used Zola's @/ syntax, which Hugo emits
literally as a relative href since there is no render-link hook. It was the
only such link under site/content; the other manuals use the relative form and
this now does too.

The CHIPS description claimed Partitioned makes the cookie unreadable from any
other page the browser visits. The partition key is the top-level site, so a
different site gets a separate cookie while pages and subdomains under the same
site share it. Overstating isolation on the page an operator reads to weigh
risk is the wrong direction to be wrong in.

And Chrome does not block third-party cookies by default: Google's April 2025
position keeps ordinary Chrome on user choice and names Incognito as the mode
that blocks. Naming Safari, Chrome Incognito and browsers configured to block
them says the same thing and stays true.

* Drop AUTH_SAME_SITE from the recommended cross-domain recipe

Measured rather than reasoned, because it reverses guidance this page has
carried for years. With only the remark42-https service taken back to the
default, both reload cases pass for anonymous and email, under a permissive
browser and under one enforcing partitioning.

The cookie jar after an anonymous sign-in says why. With the setting there are
four cookies: the server's unpartitioned JWT and XSRF-TOKEN, and the widget's
own partitioned pair. Without it there are two, the widget's pair alone, and
the session behaves identically. So the setting is doing something real, which
is what makes the passing run meaningful, and what it does is add an
unpartitioned HttpOnly JWT delivered as a third-party cookie to every listed
domain wherever the browser still permits that. Nothing needs it.

It stays documented for the configuration that does need it, which is one
without AUTH_SEND_JWT_HEADER, where the server's cookies are the only ones
there are.

One prediction the experiment falsified: the attribute case was expected to
fail on the default server-set pair. It passes, because a cross-site Set-Cookie
lacking SameSite=None is refused outright, so that pair is absent from the jar
instead of present with the wrong attribute. The manual now says so.
2026-08-23 15:49:06 -05:00
Dmitry VerkhoturovandGitHub 389189afcf Give each import request in TestMigrator_ImportDouble its own reader (#2220)
The test passed one strings.Reader as the body of both POSTs. client.Do
returns once the response headers arrive, and the import answers 202 before
the transport has finished copying the body, so the second http.NewRequest
reads the reader's Len to set ContentLength while the first request's
writeLoop is still advancing it. The race detector caught it on CI as a write
in strings.(*Reader).WriteTo against a read in NewRequestWithContext, failing
a test nothing had touched.

Reproduced in isolation to confirm the mechanism rather than infer it from the
trace: a handler that answers 202 without draining an 8 MiB body, two requests
sharing one reader, and -race reports strings.(*Reader).Len in
NewRequestWithContext against strings.(*Reader).Read on every run. It does not
reproduce in this package locally, which is why it reads as a flake.

Both requests now build their own reader over the same content. The second one
carries a full body where before it inherited a consumed one, which is closer
to what the case is about: a second import arriving while the first is running
still has to be refused.
2026-08-23 14:58:31 -05:00
Dmitry VerkhoturovandGitHub 6f40926241 Drop the origin-anchored public path from the delete-me bundle (#2219)
deleteme.ts set __webpack_public_path__ to window.location.origin plus /web/,
which discards any path prefix the instance is served under. It is inert today
because that bundle references no asset and loads no chunk, so the value is
assigned and never read, but it is wrong by construction and would resolve at
the domain root the moment anyone adds an image to that page. Removing it
leaves webpack's own publicPath: 'auto', which derives the base from the
script's URL and is right in both arrangements.
2026-08-23 14:58:27 -05:00
Dmitry VerkhoturovandGitHub 2640aaee9e Reach what http cannot: the widget over TLS, embedded cross-origin (#2214)
Every service in the suite spoke http, and the browser gates a whole class
of behaviour on the page protocol: Secure cookies, SameSite=None,
Partitioned, and any code reading location.protocol. None of it was
executed, which is how setAuthCookie came to decorate its cookies with
__Host- on https pages and survive for years.

A TLS pair joins the stack: remark42 with SSL_TYPE=static on 8443, and an
nginx serving a host page on its own name on 8444, both on a self-signed
certificate that e2e/tls/generate.sh makes and .gitignore keeps out. Every
context accepts it, and so does the readiness client, since those are the
only servers either talks to. The instance also runs with
AUTH_SEND_JWT_HEADER, which is what makes the widget write cookies of its
own: without it the client-side writer never runs on any https page here
and every assertion about the attributes it chooses is vacuous.

Three cases. Signing in across origins and then reloading, which is the one
the http cross-origin case cannot make: the widget holds its token in
memory for the life of a page, so signing in and posting says nothing about
persistence and only the reload asks whether the cookie was delivered,
stored under a name the backend reads and sent back from a third-party
frame. The cookies themselves, read out of the browser store while the
widget is embedded elsewhere, since a cookie the browser refused is absent
from that list entirely and one it kept but will not send is worse than
useless: every copy of both names has to be Secure and SameSite=None, at
least one has to be partitioned, and none may carry a __Host- prefix
nothing on either side reads. And the same reload under a browser that
blocks third-party cookies, which the widget's own partitioned pair is the
only reason to survive.

That last one needs a browser playwright does not offer: its default
arguments disable ThirdPartyStoragePartitioning outright, so a run
configured wrongly keeps every third-party cookie and the case would pass
while asserting nothing. IgnoreDefaultArgs drops that list and re-supplies
it without the one feature, and a control cookie set from inside the frame
has to be refused before anything else is read, so a playwright release
that changes the list fails as itself instead of going quietly vacuous.

All three pass against master. What TLS still cannot reach, the OAuth popup
above all, is written down in the README.
2026-08-23 14:58:23 -05:00
Dmitry VerkhoturovandGitHub 250e8ad925 Report the widget height when the sign-in panel closes (#2213)
The sign-in panel is positioned absolutely, so it grows the iframe without
growing the document: `useDropdown` measures the panel itself and posts the
sum, and a ResizeObserver on the panel keeps that number current while it is
open. Closing it resizes no box anything watches. The panel observer goes with
the element, and the document observer in `Root` sees nothing, because the
document height never changed in the first place. Nothing then tells the parent
to come back down, so the iframe keeps the open panel's height and the
embedding page carries a hole under the widget for as long as the reader stays
on it.

The effect's cleanup now reports the height, with no element, so the number is
the document's own. That is the one place both close paths reach: the click
inside the widget, and the clickOutside message the host page posts when the
reader clicks anywhere else.

TestGeometry_HeightFollowsTheAuthPanelAndTheTextarea covers this and has been
intermittently green: whether the frame comes back down without the fix depends
on timing, and it fails on every run here while CI has been passing. The unit
test fails with the cleanup reverted.
2026-08-23 00:26:31 -05:00
Dmitry VerkhoturovandGitHub 4793c1cd2c Fill the instance URL into the embedded frontend at serve time, and stop pinning compressor output in tests (#2198)
* Assert what the image endpoints promise rather than the compressor's output

Three tests pinned the exact bytes or the exact length of an encoded
image, so they fail on any toolchain whose deflate or png encoder emits
something different. CI pins go 1.25 and passes; go 1.27 fails all three,
while the images themselves are perfectly valid.

TestRest_QR now decodes both the golden file and the response and
compares the pixels, which is the same assertion about the qr code and
none about the encoder. The two resize cases assert the decoded image
fits the box resize was given and touches one of its sides, which is what
fitting to a box means and what the function actually promises.

Resolves #2200.

* Fill the instance URL into the embedded frontend at serve time

The widget falls back to a compiled-in URL whenever a page omits
`remark_config.host`. The bundler cannot know that URL, so it emits
`{% REMARK_URL %}` and each distribution substitutes it: the docker image
rewrites the files under its web root at container start, and the release
binary, which serves the build embedded in itself, had nothing doing it.
`prepare-release-assets.sh` filled the marker with `http://127.0.0.1:8080`
before the embed instead, so every copy of the binary shipped pointing at
the visitor's own loopback address, and on an https site the request is
blocked as mixed content besides.

It has been that way since v1.11.0, the first release to embed the
frontend, and the earlier binaries embedded none, so the tarball has never
served a correctly addressed widget.

The placeholder now survives into the embedded copy and the file server
fills it with the configured `REMARK_URL` as it serves, which is what the
docker image already does to its own copy. The image no longer bakes the
loopback address into its embedded copy either, so the fallback it keeps
for a missing web root is correct rather than misleading.

Substituted in html, js and mjs, the same set `docker-init.sh` rewrites,
and the served size is the substituted one so a response is neither
truncated nor left hanging.

Nothing exercised the marker the frontend build emits wherever the instance
url belongs. Every page in the suite sets `remark_config.host` from its own
origin, so the compiled-in fallback is never read, and a distribution that
stopped substituting would keep the suite green.

Two tests. The first reads the served bundles and pages back and asserts the
marker is gone from each and that what replaced it is this instance. The
second covers what the substitution is for: the widget document carries no
host of its own, since `iframe.html` builds its config from a query string the
parent never puts one in, so everything it requests is addressed with the
compiled-in url. It asserts the widget renders and that the config request
went to this instance.

The demo pages cannot show the second. Their loader builds the bundle's own
script url from `remark_config.host`, so a page without one never gets as far
as loading the widget.

Verified by disabling both substitution paths, the serve-time one and the
docker image's, and rebuilding: both tests fail. Editing the files on disk is
not enough, since the file server substitutes as it serves.

The served body now depends on remarkURL, but cacheControl builds its
etag from version and path only. An operator who notices the widget is
addressed to the wrong host, corrects REMARK_URL and restarts the same
binary gets 304 on revalidation, so the client keeps a bundle pointing
at the old host. Cache-Control is no-cache, so it revalidates every time
and never ages out of that state either.

That is the exact situation this substitution exists to fix, so the
validator has to carry the url.
2026-08-22 13:15:27 -05:00
Dmitry VerkhoturovandGitHub 23be25d84a Fix seven widget defects, including the cookies the separate-domain setup needs (#2197)
* Drop the frontend workspace root and re-resolve the lockfile

`frontend/` carried a `package.json`, a `pnpm-workspace.yaml` and the lockfile
for a workspace of exactly one package. Two manifests meant two places to
declare a version, and the app pin was the one that did not win: `preact` and
`@babel/core` were each written twice, and a bump to the app manifest alone
would have been a silent no-op, since `pnpm.overrides` decides and it lived at
the root.

Everything pnpm reads now lives in `frontend/apps/remark42`: dependencies,
`packageManager`, `engines` and the overrides. `frontend/` keeps `.nvmrc`,
`.husky` and `CLAUDE.md`, none of which pnpm reads. The directory nesting
stays: every path in the repository points at `frontend/apps/remark42`,
including the published contributing docs, so moving the package up would have
rewritten 14 files to no benefit.

Moving the manifest kept the old resolutions verbatim, which left optional peer
subtrees the tree no longer reaches: `ts-node` under jest, `@swc/core` under
webpack, `vitest` under `@testing-library/jest-dom`, `tslib` under
`webpack-dev-server`. None is referenced by any config or source file here.
Re-resolving drops 137 packages and moves 59 to versions already permitted by
the ranges in the manifest, 1446 to 1308, with no direct dependency changing
version: the five that look changed differ only in their peer suffix. Every
file `pnpm build` produces is identical in size before and after.

The frontend-deps stage of the Dockerfile sets `CI=true` so the `prepare`
script skips husky, which has no git repository to install hooks into there.

* Stop markdown-only changes triggering heavy workflows, and check the documented versions

`ci-backend.yml`, `ci-build.yml` and `ci-frontend.yml` all end their path
filters with `!**.md`. The e2e workflow did not, so a change to any markdown
file under `frontend/` or `backend/` matched its `frontend/**` and `backend/**`
entries and started a docker build and the whole browser suite. The release
filter had the same hole and two of its own: it names `README.md` and `LICENSE`
on purpose, since `.goreleaser.yml` packages both, so it now excludes markdown
under `backend/` and `frontend/` only. `CLAUDE.md` and the installation page
were listed as well, and neither is packaged.

`ci-site.yml` goes on matching markdown, which is right, since the site is
built from it. It excludes `CLAUDE.md`, so a future `site/CLAUDE.md` cannot
start a site build, and `site/README.md`, which documents how to build the site
rather than being part of it.

The installation page tells a reader that a source build needs Go 1.25, Node
24+ and PNPM 10. Nothing kept those in step with `backend/go.mod`,
`engines.node`, `packageManager` and `.nvmrc`, and the drift is silent: a wrong
version in the docs builds and tests exactly as well as a right one. `.nvmrc`
is the pin with form here, having sat at 16 through the whole node 20 migration
because nothing red ever pointed at it. The check compares each stated version
against its source and holds `.nvmrc` to `engines.node`, and it fails when the
page states no version at all, so removing the claims cannot turn it into a
check that passes by comparing nothing.

Its own workflow rather than a step in an existing one, since the inputs span
the backend module, the frontend manifest and the site.

* Fix the cookie fallback page, asset path, message senders, auth teardown and cookies

Two defects with the same origin: 5825a55b, the January 2021 frontend
rewrite, first released in v1.7.0.

It removed the build entry for comments.html while leaving both the
template and the link to it in place, so the page the auth panel offers
when third-party cookies are blocked has been a 404 ever since, for
exactly the reader who has no other way in. The template needed no
changes; it is built again, and an e2e case now opens it on a thread
carrying a comment and waits for that comment, so the page being served,
its inline script running and it asking for the thread named in its own
query string are all covered. Against an image built without the plugin
entry that case fails on the 404, which is the regression it exists for.

It also fixed the public path to the domain root, so an instance mounted
under a prefix, which manuals/separate-domain documents, asked for
/web/google.svg when its own icons live under that prefix. Fifteen
provider icons in remark.mjs and one in last-comments.mjs. The path is
now derived from the url the bundle was loaded from, which is correct for
both arrangements, and the file loader no longer overrides it.

The host page also accepted postMessage from any window: every frame on a
page can reach window.parent, and the handler resizes the widget, scrolls
the page and opens the profile overlay. It now ignores anything that did
not come from a frame this module created.

A fourth, in the same family: the OAuth flow never tore its polling down.
`subscribed` was declared, checked and cleared but never set, so the guard
against a second subscription was dead code and every provider click
attached another listener pair. The five minute deadline then rejected
without unsubscribing, leaving those listeners and a retry that
reschedules itself for as long as getUser returns null. Cross-domain is
where getUser never stops returning null, so a reader on the arrangement
manuals/separate-domain documents was left polling /auth/user once a
minute for the life of the page, against a route capped at 2 req/s. It
also rejected with no argument, and the caller stores that as the error
state, so the interface had undefined to render. The deadline now tears
the subscription down and rejects with an error.

The message check had a second half. Hardening the parent left the widget
document trusting any sender, and it acts on signout and theme, so
anything holding a reference to the frame could sign a reader out.
`auth.hooks` already checked `event.source !== window.parent`; that check
is now a shared `isFromParent` and the three listeners that lacked it use
it too. The origin cannot stand in for it, since the host page is
whatever site embeds the widget and `ALLOWED_HOSTS` is enforced server
side through `frame-ancestors`.

And createInstance stacked its listeners. It reuses the marked iframe
instead of building one, but installed three listeners plus a title
observer on every call, while destroy could only reach the newest
closure, so a second call without a destroy stranded a set for good. The
listeners of the current instance are now detached before the next set
goes on. Reuse and the ignored config are unchanged: that contract is
open in the backlog note and not settled here.

The auth cookies the embedded case needs were not being delivered, in
both halves of the client's own writer. The name was decorated:
setAuthCookie prefixed with __Host- whenever the page was https, so a
real deployment wrote __Host-JWT and __Host-XSRF-TOKEN while the backend
looks for JWT and the fetcher reads XSRF-TOKEN, and nothing anywhere
reads a prefixed name. Nothing caught it because the prefix is applied
from the page protocol and every test and the dev server run on http;
there is now a second suite pinned to an https page, which is the only
condition that shows it. And the attributes could not be delivered: both
were SameSite=Strict, judged against the top-level site and not the
request's own origin, so a Strict cookie is never sent from a
third-party frame, which is the entire configuration this code exists
for. They now follow the embedding, Strict while the widget shares its
page origin and None with Secure and Partitioned once it does not, since
that is the only third-party form browsers still accept. Over http in a
third-party frame no combination works, and the strict form is written
instead of one the browser would reject outright.

That leaves the client half of #1877 working, whose reporter wanted
AUTH_SEND_JWT_HEADER for exactly this arrangement, and whose first half
merged as #1929. The server's own cookies still carry no Partitioned;
that is upstream work in go-pkgz/auth.

Two plan changes. A review pass corrected its central Path B premise,
which said the first document render is anonymous permanently, in every
configuration: it is anonymous in the configuration remark42 ships,
go-pkgz/auth exposing XSRFIgnoreMethods and remark42 leaving it unset.
The door is not shut, it is closed by a setting, and opening it is
scoped security work and not a flag flip, because GET /deleteme
deletes every comment a user has written and is a GET so the emailed
link works. And the separate-domain arrangement is promoted from a
constraint bullet to a named requirement with acceptance criteria, since
a test that signs in and posts without reloading passes while
persistence is entirely broken.

Review found a seventh, and it was reachable only because of the first:
comments.ejs built its title with innerHTML from the url query
parameter, so restoring the build entry made a reflected XSS live on the
instance origin, where the page is a top-level document, frame-ancestors
does not apply and the /web CSP allows unsafe-inline. The anchor is now
built through the DOM with textContent, and only http and https reach
href, since escaping alone leaves a javascript: url working. Two e2e
subtests pin both halves, and mutation testing separates them: restoring
innerHTML fails four assertions, while keeping the escaping and dropping
only the scheme guard fails the href one alone.

Review also found the poll teardown test did not exercise the poll.
handleWindowVisibilityChange is reachable only from the two listeners
and from the retry it schedules itself, and the test dispatched neither,
so no request was ever made and the assertion compared zero to zero; it
passed with the teardown reverted. It now dispatches focus, asserts
requests are being made and keep coming, and only then that they stop.
And the teardown could not cancel an in-flight getUser: a null resolving
after the deadline ran the code past the await and scheduled a fresh
retry with nothing left to clear it. A closure-local flag checked after
the await stops that, chosen over a second guard at the top of the
handler because only one of the two is detectable by mutation and this
is the one that prevents the stray timer rather than neutering it.

The inline handler in the iframe template accepted messages from any
window while acting on them through location.replace and document.title.
It now takes only the parent, the same check the host page side makes.
2026-08-22 12:34:24 -05:00
Dmitry VerkhoturovandGitHub 4d5dae20e2 Broaden the e2e suite from 21 cases to 63, and harden its harness (#2196)
* Pin the published /web surface in the e2e suite

#2178 renamed the widget bundles from .js to .mjs and the URLs earlier
releases served under those names stopped resolving. Three were noticed
from the demo site; the rest, including every locale chunk, were found
only by requesting the whole surface of both images over HTTP. #2192
restored them with a server-side alias, and nothing in the suite would
have caught the break or would notice it returning.

Two cases with deliberately different criteria. The documented names are
written out, because the documentation decides that list and not the
build: an operator pastes privacy.html into an OAuth application, the
nginx manual proxies index.html by name, and the integration guides start
from the embed script. Everything else is taken from the build itself, so
whatever the bundler emitted has to serve identical bytes under its
legacy .js name and parse as a classic script, which is the premise
serving one under the other rests on. A third case requests a name that
does not exist, without which a fallback serving one page for everything
would keep the whole table green.

All of them check the content type as well as the bytes: nosniff is set
on every response, so a bundle served as text/plain is as broken as one
that 404s while comparing equal.

On e3d1d0e2, the commit before the alias, this fails with 32 red subtests.

* Cover the widget behavior the e2e suite never drove

Removing npm from the widget takes the jest tests with it, and everything
here was protected by jest or by nothing at all.

Signing out was untested at every level, and the panel repainting is the
half that always works: the assertion after the reload is the one that
catches a session the server never ended. The edit form has to hand back
the source that was posted and not the rendered comment, which #2040
shipped the other way round, taking every entity and tag the author had
written with it. A draft has to survive a reload and be gone once the
comment is posted. A refused comment has to stay in the form with
something said about it, so that case drives the backend's own
restricted-words code and then retries with the route removed.

Uploads had no browser coverage in either direction. The posted case
asserts naturalWidth instead of visibility, since a broken src still
renders as an empty box, and the failing case holds the intercepted
request long enough for the in-flight state to be observed: without that,
"the text is unchanged afterwards" would hold for an upload that never
started.

Moderation and reader-side hiding are asserted from a page other than the
one that made the change. Hiding seeds a second author, so it proves one
person is hidden and the thread not emptied, and both the blocked
author and the moderated one carry the run id in their names: a block and
a verification are properties of the user and outlive the run in the
stack's database, so a fixed name works exactly once.

Locales were the largest hole. Each catalog is a chunk fetched at
runtime, and loadLocale falls back to english on any failure instead of
throwing, exactly as an unrecognized name does, so every case compares
the rendered string against the file on disk. One case per catalog
covers that they are all served and render; another fetches one through
the widget document and asserts it parsed, which is the half a chunk that
serves but fails to parse would slip through. The delete page and the
last-comments stylesheet had no coverage of any kind.

Two things the suite itself needed. The widget's own aria-label is
translated, so commentFormSel only ever finds an english widget and a
localized case cannot use widget(). And the auth probe is capped at two
requests a second for the whole suite, hard-coded in rest.go: the added
cases pushed past it and the suite began manufacturing its own 429s,
which render as a signed-out widget and fail whichever test happens to be
signing in, so the pacing gap is wider and the locale cases stub the
probe they never needed.

* Harden the e2e harness against silent failures and stale stacks

Three things the suite could not tell you about itself.

A browser failure nothing asserts on now fails the test that caused it.
Uncaught exceptions are the ones worth the machinery: a widget throwing
while it renders leaves most of these cases green, since they assert on
elements the browser lays out either way. Rate-limit responses are
recorded as well as logged for the same reason. A test driving an error
path declares what it expects by substring, so a case that means to
break something says which thing.

The stack the suite adopts is now checked against the sources under
test. Every checkout builds the image tag the compose file names, so a
stack from another worktree, or from this one before an edit, answers on
these ports and passes every readiness probe while serving code nobody
is looking at. stamp.sh digests what goes into the image, compose passes
it as the revision label, and a mismatch is refused with what to do
about it. Checked after our own build too, or a stamp that never reaches
the image would be a guard that silently passes everything.

assertSignedIn no longer waits out the whole timeout on a refused status
read. /auth/ is capped at two requests a second for the entire suite, a
bare literal at rest.go:242, and a case signing in on two pages spends
that twice; when the read that repaints the panel is the one the limiter
turns down, the widget shows signed out over a session that exists and
no later request will ask again. The short first wait now ends in a
focus handoff, which the widget answers by re-probing, and only then
does the real wait run. A sign-in that genuinely failed still fails,
since the second read finds no state either. That is what
TestComment_AdminPinsAndVerifies and TestComment_BlockedAuthorCannotPost
were failing on in CI while passing locally.

signInAnon takes the page for that reason, and its callers pass it.

* Cover iframe geometry, the embed contract and five deployment modes

Seventeen cases for the parts of the widget that broke repeatedly and
that nothing here could see, plus the areas jest was the only check on.

Geometry is the biggest of them. The widget measures its own document
and posts the number for the parent to apply, and every way that has
gone wrong is invisible to assertions about elements, which read the
same whether the frame is right, twice too tall or a strip. So: the
first height the parent is given describes rendered content and not the
preloader, the frame matches the document it holds and does not stand
24px taller, no_footer leaves the last comment inside the frame, and the
frame follows the sign-in dropdown and the growing textarea and comes
back down again. Each was verified by reintroducing the defect it covers
and watching it fail: a 63px report before the real one, six pixels of
body padding, and a frame sized under the content.

The embed surface is the other half the widget cannot see. An element
placeholder gives way to exactly one iframe carrying the embed's own
marker, a second createInstance reuses it, destroy takes it away with
its handles, and a theme change after load reaches both the element and
the document. A page that posts a message of its own, which is all
embed.ts's own title observer does, no longer empties an open login
form.

Five configurations that cannot share an instance get one each, since
each changes the widget for every reader: an admin's unlimited edit
window, a session carried in a header and not a cookie, an instance with
no auth provider to offer, anonymous voting, and the notify module,
without which email_notifications is false and the subscribe control
never renders at all. The subscription round trip is the one place a
token from a real message is exchanged for state the server keeps.

Two things surfaced there and are left alone, both said so in place. The
panel confirming an unsubscribe cannot be observed, because the click
changes the step and the dropdown closes on an element no longer in the
rerendered view, which the component notes as its own awkwardness; the
case asserts the request and the answer, which is what decides whether
the reader still gets mail. And the widget takes the subscribed state
from user.email_subscription, absent from what it hydrates the user with
on the next load, so after a reload it offers to subscribe somebody who
already is.

simple_view needs no instance, being a query parameter, so both branches
run against the main one. A transient failure of the status probe has a
case too: the session belongs to the server, and one refused answer must
not end it.

The suite runs about four and a half minutes now, so the workflow's own
budget goes to 20m to match the Makefile, well inside the job timeout,
and the workflow stamps the stack it starts the way the Makefile does.
The locale case that loads the widget document directly is renamed for
what it protects, the origin and CSP its chunks are fetched under.

Telegram gets no test: the base URL is formatted inline inside
go-pkgz/auth, so nothing here can point it elsewhere, and the fix
belongs upstream in v1 and v2 both. Reported as #2208.

Three things CI found that a laptop cannot. The anonymous sign-in form
validates its input against pattern="[\p{L}\d\s_]+", and E2E_RUN_ID is
"<run id>-<attempt>" on a runner, so every username built from it
carried a hyphen the browser refused to submit: no request was made and
the case waited out its timeout on a panel that was never going to
change. Names are built by anonName now, which drops what the pattern
does not allow and adds the pid, so a second run against a surviving
stack does not meet its own blocked and verified users. signInAnon waits
for the request the submit makes, so the next such refusal fails as
itself.

The admin instance takes its admin from an email address, not a name.
remark42 hashes an anonymous id from the name and the client address
together, to tell apart two people picking the same name, so the id
written into ADMIN_SHARED_ID belonged to nobody on a runner and the
instance had no admin at all: the countdown stayed, and the backend
refused the edit. An email id is sha1 of the address, which is the same
everywhere.

The subscription case clears its own precondition and confirms through
the page's session. The dev user is shared and a subscription outlives
the run, so the panel opened on the subscribed step; and the panel moves
to that step while its token textarea is still on screen, leaving no
moment at which the control to submit it exists.

Three settings of remark_config get cases of their own, none having had
any: __colors__, which is the one setting that travels through
window.name and not the query string, so nothing else in the suite would
notice the path going; the url override, which is how a canonical
address keeps one conversation across pages that differ; and the
subscription controls an integrator turns off, with the both-shown case
as the control.

Writing the url case turned up a backend defect, reported as #2204 and
not fixed here: a thread url containing "&" cannot be commented on at
all. Sanitize runs the locator
through SanitizeAsURL, which round-trips it through bluemonday, so the
url is stored html-escaped; the bucket is created under the escaped key,
the read-back uses the real one and answers 500, and every later find,
count and feed asks for the real url and is told the thread is empty.
Any page addressed with two query parameters is affected. The case uses
a single-parameter url for that reason.

Five more from the same audit, none needing a service. Collapsing a
thread shrinks the frame, which every other geometry case would miss:
they all assert growth, and a widget that only grew would satisfy them
while leaving a hole under each collapsed thread. Voting gains the
direction nothing covered, downvoting and its survival of a reload, and
the rule the other vote cases work around, that your own comment offers
no buttons and the backend refuses the vote anyway.

A vote with the X-XSRF-TOKEN header stripped has to be refused. That
check is why a document navigation, an iframe src among them, is always
anonymous and why the widget hydrates its user over XHR, so anything
designed around that wants it pinned.

An unrecognized locale has to render English, which is loadLocale's only
observable guarantee: it falls back the same way for a name it does not
know and for a chunk it cannot fetch. And a comment's timestamp has to
be the reader's own, which is the one part of rendering that cannot move
to the server, asserted from a context in Kiritimati against the same
Intl the widget uses.

A host page on an origin the widget is not served from, which is the
separate-domain setup the manuals describe and the configuration readers
actually hit problems with. Every other host page here is served by
remark42 itself, so the cross-site path was never taken: an nginx on its
own name and port serves e2e/hostsite, and the case asserts the frame is
revealed, which means its document loaded and reported itself inited
across the origin boundary, and that the thread it renders is the one
the page's address names. Signing in is left out on purpose, an embedded
cookie needing SameSite=None, which browsers take only as Secure, and
this stack speaks http; that is #1139 and not something a case here can
settle.

The other half is ALLOWED_HOSTS. The no-provider instance names only
itself, so a page elsewhere embedding it is refused by the browser, the
document never runs, and the reveal comes from the widget's own fallback
five seconds later. Both directions are worth holding: without the
fallback a mistyped host leaves a permanently invisible widget with
nothing to say why, and without the refusal the setting does nothing.

The host page's title reaching the stored comment gets a case, the path
running the other way from everything else here: the page posts its
title into the widget, the widget sends it with the comment, and it is
what a feed and the admin listing show. Set after the widget is up, so
it covers the observer embed.ts installs and not the value read at boot.

max_shown_comments has no case. The setting reaches the widget, appears
in the iframe's query string and changes nothing: four comments render
with it set to two, which is #812, still open, where the reproduction is
now recorded. A case for it would be red on master.

The README gains what the suite cannot reach. Every service here speaks
http, so anything the browser gates on the page protocol is invisible: a
Secure cookie, anything keyed on window.location.protocol, and the
SameSite=None with Secure and Partitioned form that is the only one an
embedded frame can still use. That is not hypothetical, setAuthCookie
having decorated its cookies with __Host- on any https page and survived
precisely because nothing here runs on one. The cross-origin case names
the assertion to add if the stack ever gets TLS, which is the reload:
the widget holds its token in memory for the life of a page, so signing
in and posting without reloading passes while persistence is broken.

And the trap waiting for whoever acts on that: playwright's own default
--disable-features argument carries ThirdPartyStoragePartitioning, and it
beats both --test-third-party-cookie-phaseout and
--block-third-party-cookies passed through Args, so a run meaning to
prove the third-party case keeps an ordinary third-party cookie exactly
as it would with no flags at all. IgnoreDefaultArgs is the lever, and a
blocking run has to assert a control before anything it reports can be
believed. None of it reaches the widget's own storage fallback either:
IS_STORAGE_AVAILABLE stays true with partitioning enforced, chromium
partitioning localStorage instead of denying it, so comments.html needs
webkit and not a flag.

The downvote case now actually corrects. It claimed the score ends where
the second vote leaves it and never cast one, so #728, a reader taking a
vote back, could break with it green. It also turns out the opposite
vote takes the first one back instead of flipping it, so the score
returns to zero and never reaches +1, which is what the case asserts,
before and after a reload. Renamed for what it covers.
2026-08-22 11:59:37 -05:00
UmputunandGitHub a82dc8d3f1 Restore the legacy /web/*.js URLs and fix iframe reuse (#2192)
* Serve the legacy /web/*.js names from their .mjs siblings

The build emitted <name>.js alongside <name>.mjs until the two compilations
were collapsed into one. Dropping the second compilation was right, but it
removed URLs the project itself had published: the v1.16.4 SPA documentation
named /web/embed.js directly and its loader snippet requested .js. Pages that
hard-coded those names now 404 with no deprecation.

webFiles.Open retries a missing .js against the .mjs sibling. The bundles
contain no import or export, so the same bytes serve both names. The retry
runs only once both sources report the name missing, so a real .js still
wins, and an unreadable sibling reports its own error rather than being
flattened into the requested file's 404.

Related to #2178

* Reuse only the comments iframe embed created

createInstance took root.firstElementChild as its iframe, so anything a page
left inside #remark42 was adopted instead. A <noscript> fallback became the
"iframe", createIframe never ran, and the height messages went to an element
that cannot show comments.

That also defeats the placeholder support, which promises content in the root
is cleared once the iframe reports inited: a text placeholder works, but any
element placeholder is mistaken for the iframe, so inited never arrives and
the cleanup never runs.

The iframe now carries data-remark42-iframe and the lookup is scoped to a
direct child, so a second createInstance still reuses it while nothing else
in the root can be adopted.

Related to #1990

* Assert the backup contents rather than the compressed size

TestBackup_MakeBackup and TestBackup_Do pinned the gzip output at 52 bytes,
which ties them to the exact output of compress/flate. The same input encodes
to 57 bytes on go 1.27, so both fail for anyone building on a toolchain newer
than the one CI pins.

They now read the backup back and compare it against what the exporter wrote,
which is what the tests were reaching for and does not move with the
compressor. The payload is a shared constant so the two cannot drift.
2026-08-22 03:09:40 -05:00
Dmitry VerkhoturovandGitHub e3d1d0e23e Create the e2e trace directory before writing a trace (#2194)
`newPageOn` writes a trace into `traces/` when a test fails, and never created
that directory. It is gitignored, so a fresh checkout does not have it.

Traces were not in fact being dropped: the driver creates the parent of the
trace path itself, checked against the version this module pins rather than
assumed. The directory is created here anyway because nothing in the suite
states or tests that dependency, and the missing directory has been raised in
review on #2180 and again on #2193, each time needing the driver checked before
it could be answered.

One visible difference on a fresh checkout: the directory now arrives at 0750
rather than the 0755 the driver's own mkdir leaves, both measured. It runs only
on a test that has already failed, and logs its error rather than swallowing
it, matching the Stop call below it.
2026-08-22 02:47:33 -05:00
Dmitry VerkhoturovandGitHub 49bf83b09c Address the review follow-ups from #2188, #2189 and #2190 (#2193)
* Read the collapsed-threads key through getJsonItem

`getFromLocalStorage` parsed the stored string directly, so anything
malformed under `__remarkCollapsed` threw out of `restoreCollapsedThreads`.
That call sits in `remark.tsx` ahead of the `render`, so the throw took the
whole widget with it: the reader was left on the preloader, over a view
preference.

`getJsonItem` in `common/local-storage.ts` already wraps a parse of a
localStorage key and returns null on failure, and null is a shape the check
below already reads as empty. The rest of that function is total against
whatever the browser holds, and the bare parse was the one way in.

* Stop retrying a failed e2e test in CI

The suite went in with one gotestsum rerun. It has no failures on record to
justify that: 31 CI runs since it landed, all green, and no rerun report has
ever been produced. A retry is what turns an intermittent regression into a
green build, and while the suite is this young its own failures are the
evidence worth keeping.

`E2E_RUN_ID` stays. It stamps the threads a run works on with the CI run id, so
a thread url in a trace or a log names the run it came from. It carries no data
across: the stack is disposable, and a local run under the same id gets those
urls on an empty database.

* Stop two chooseUnusedPort comments claiming collisions cannot happen

All four copies listen on :0, read the assigned port, close the listener
and bind later, so nothing holds the number across that gap and another
binary can take it. The copies in app/cmd and app/rest/api call a collision
very unlikely, which is accurate; the ones in app and the example module
said binaries never land on the same number, which is not, and a comment
ruling out a port collision is what would send the next person chasing one
somewhere else. All four now read the same.

Closing the window rather than describing it means the server binding :0
itself and reporting the address it got, which is a larger change.
2026-08-22 02:23:56 -05:00
Dmitry VerkhoturovandGitHub 0b651dddd4 Make backend tests wait on conditions instead of durations (#2190)
* Make backend tests wait on conditions instead of durations

The backend workflow has a long tail of runs that fail once and pass on
a rerun. Every one of them comes down to a test assuming an operation
finishes within some duration rather than waiting for the state it
needs. Three were reproducible and each was reproduced against the old
code before being changed: TestServerAuthHooks minted a token that lived
one second and never tested expiry, so a slow runner turned the first
POST into a 401; TestServerApp_AnonMode saw "connection refused" because
waitForHTTPServerStart returned silently after three seconds and left a
later assertion to fail with something unrelated; TestFsStore_Cleanup
slept 200ms against a 300ms ttl that Cleanup widens to 400ms with its
commit grace, so roughly 100ms of stall collected an image meant to
survive.

Fixed sleeps before asserting on asynchronous work are replaced with
polls on the condition itself, using require.Eventually and
require.EventuallyWithT, and require.Never where the assertion is that
something did not happen. Polling closures assert on the CollectT they
are handed rather than on t, since testify runs them on another
goroutine, and polls that issue HTTP requests stay under the rate limit
on the routes they poll through.

Where a test needs time to have passed, the clock input is pinned
instead: staging ages are stamped with os.Chtimes on both sides of the
cleanup boundary right before each call, which also makes the 100ms
commit grace an exact case rather than something no assertion reaches,
and the RSS tests set store.Comment.Timestamp explicitly rather than
racing the wall clock into the first 100ms of a second so pubDate
matches.

chooseUnusedPort takes a port from the kernel's ephemeral range. Picking
at random out of a fixed 10000-port window let two package binaries,
which go test ./... runs concurrently, land on the same number between
the probe closing and the server binding. The start helpers fail naming
the port they waited on, and the SSL tests wait on the redirect port as
well as the TLS one.

Arbitrary budgets that nothing tests are gone: ten HTTP clients with a
one-second timeout against bolt-backed import and export, the "should
take about 100msec" assertions, and a one-second bound on noticing an
already cancelled context. Shutdown stays bounded at ten seconds so a
hang is still caught.

Two assertions get stronger. TestServerAuthHooks accepted 403 or 401
from a blocked user, an alternative that existed only because the short
token could expire mid-test; it is deterministically 403 now.
TestAdmin_BlockedList asserted two users blocked while one carried the
same 150ms ttl the next step waits to lapse, so the halves raced each
other.

goleak stops reporting the regexp2 clock goroutine, which chroma pulls
in for syntax highlighting and which lives for up to a second after the
last match with a timeout; it ends on its own but a binary finishing
inside that window was reported as leaking, and this suite now finishes
sooner. The ignore for net/http.(*Server).Shutdown goes the other way:
it no longer matches anything, with both packages run fifteen times each
under CPU oversubscription to confirm.

Two gaps the change would otherwise have opened are covered directly
rather than left to the side effects that used to cover them. The
one-second token was the only thing exercising the authenticator's
ClaimsUpd hook on refresh, so TestServerApp_ClaimsUpd now calls the hook
itself and checks admin, blocked, email and restricted-name
impersonation, including the two pass-through cases. Lifting the
open-route limit removed the last incidental exercise of the rate
limiter, so TestRateLimiter drives a burst past the allowance and checks
the refusals and that the limit is per client. Both run without a wall
clock, and both were confirmed to fail when the behaviour they cover is
removed.

Production code is untouched. The two sleeps outside test code, the 429
backoff in cmd/cleanup.go and the submit poll in store/image/image.go,
are left alone: no CI failure implicates them.

Test sleeps drop from 67 to 21, all of them either inside a
testing/synctest bubble or a poll interval. The suite runs in about 22
seconds instead of 46, mostly because
TestPublic_FindCommentsCtrl_ConsistentCount no longer paces a hundred
subtests with an 80ms sleep each to stay under the open route limit. The
300s per-package budget now matches across both workflows, the race_test
target and the documented command, and CLAUDE.md records the convention.

with '#' will be ignored, and an empty message aborts the commit. # #
Date: Sat Aug 22 01:12:31 2026 +0100 # # interactive rebase in progress;
onto 7c312da1 # Last command done (1 command done): # reword deb6cbf1 #
Make backend tests wait on conditions instead of durations # Next
command to do (1 remaining command): # reword 262e6dc2 # Apply go fix
under Go 1.27 # You are currently editing a commit while rebasing branch
'fix/backend-test-flakiness' on '7c312da1'. # # Changes to be committed:
.github/workflows/release.yml # modified: CLAUDE.md # modified: Makefile
modified: backend/_example/memory_store/server/rpc_test.go # modified:
backend/app/cmd/import_test.go # modified:
backend/app/cmd/server_test.go # modified: backend/app/main_test.go #
modified: backend/app/rest/api/admin_test.go # modified:
backend/app/rest/api/middleware_test.go # modified:
backend/app/rest/api/migrator_test.go # modified:
backend/app/rest/api/rest_private_test.go # modified:
backend/app/rest/api/rest_public_test.go # modified:
backend/app/rest/api/rest_test.go # modified:
backend/app/rest/api/rss_test.go # modified:
backend/app/rest/proxy/image_test.go # modified:
backend/app/store/image/fs_store_test.go # modified:
backend/app/store/service/service_test.go # modified:
docs/backlog/api-tests-deadlock-on-macos.md #

* Apply go fix under Go 1.27

Go 1.27 extends go fix with the modernizers, so `go fix ./...` now
rewrites patterns the language has since replaced. Running it across all
three modules produces this: legacy sync/atomic calls on plain integers
become the atomic types (notify.Service.closed, image.Service.term and
submitCount, and several test counters), reverse index loops become
slices.Backward, a Split-then-index becomes strings.Cut, counted loops
become range over an int, and interface{} becomes any in the e2e suite.

The example module needed no changes. The e2e module is behind a build
tag, so it only matches with `go fix -tags e2e ./...`.

One knock-on: prealloc can see the bound of a loop once it is written as
range over an int, so the slice it feeds is now preallocated.

with '#' will be ignored, and an empty message aborts the commit. # #
Date: Sat Aug 22 01:32:09 2026 +0100 # # interactive rebase in progress;
onto 7c312da1 # Last commands done (2 commands done): # reword deb6cbf1
262e6dc2 # Apply go fix under Go 1.27 # No commands remaining. # You are
currently editing a commit while rebasing branch
'fix/backend-test-flakiness' on '7c312da1'. # # Changes to be committed:
backend/app/migrator/native.go # modified: backend/app/notify/notify.go
backend/app/rest/api/rest_private_test.go # modified:
backend/app/store/comment.go # modified:
backend/app/store/image/image.go # modified:
backend/app/store/service/service_test.go # modified:
backend/app/store/service/title_test.go # modified: e2e/e2e_test.go #
modified: e2e/widgets_test.go #
2026-08-21 22:17:44 -05:00
Dmitry VerkhoturovandGitHub b6975af63c Fix collapsed threads not restoring, and the clock skew correction (#2188)
* Fix collapsed threads not restoring, and the clock skew correction

Collapse state was kept as a flat list of `siteID_url_commentID` strings
and read back by splitting on `_`. Any underscore in the url, the site id
or the comment id made the pieces impossible to tell apart, so a page
whose url contains one lost its collapsed threads on every reload, and one
page's entries could be read or deleted as another's: `/post` matched
everything stored for `/post_2`, and a site id of `blog` matched `blog_ru`.
No separator fixes that, since every candidate can occur inside the values,
so the ids are now nested under the site and the url instead. Anything
stored in the old shape reads as empty: collapsed threads are a view
preference, and re-expanding them once is not worth a migration.

The e2e suite had been stripping underscores out of its own thread urls to
work around this, which left its collapse test unable to fail on the bug it
covers. That workaround is gone, and the test now fails without this fix.

`serverClientTimeDiff` was written in seconds and added to an epoch in
milliseconds, so the correction it exists to apply was a thousandth of the
real skew. It is now milliseconds, and named for the unit.

A response with no usable `date` used to fall back to a zero timestamp,
which already made the "skew" about twenty days and would have made it
fifty-five years once the units were right. Nothing is stored now unless
the reading is plausible, since `Date.parse` is lenient enough to turn junk
into a date and let an absurd value through the branch that parses.

The score tooltip reports controversy again when there is any. It has been
dead since the vote component was rewritten in 0e4ae6e0, which moved the
score into its own component and left the line behind commented out; the
value has been passed in and dropped ever since. Unlike the original it
stays out of the way when there is no controversy, which the backend sends
as an absent field rather than a zero.

* Drop the nested frontend dockerignore

Docker reads `.dockerignore` from the build context root only, and nothing
builds from `frontend/`: every context in the repo is the repository root,
apart from the site, which has its own. There is no Dockerfile under
`frontend/` any more either. So the file was never consulted, and both
lines it carried, `/.vscode/` and `/.idea/`, are already in the root
`.dockerignore` verbatim.
2026-08-21 22:12:19 -05:00
Dmitry VerkhoturovandGitHub 4fca268dc6 Pin staging ages in TestFsStore_Cleanup instead of sleeping (#2191)
The test slept 200ms, ran Cleanup with a 300ms TTL and then asserted the
second and third staged images survived. Cleanup collects anything older than
the TTL plus a 100ms commit grace, and the second image was already 300ms old
by then, so a runner that stalled ~100ms anywhere in the setup aged it past
the line and the assertion failed with "file on staging".

Age comes from the file's modification time, so the test now sets it with
os.Chtimes on both sides of the boundary immediately before each Cleanup call:
the image meant to be collected is backdated an hour, the ones meant to
survive are stamped at now. That leaves no window for a stall to age a file
into the wrong bucket, and drops 600ms of sleeping.

Verified by injecting a stall into the setup: 250ms reproduces the failure on
the current code, while the version here survives 2s.
2026-08-21 22:06:58 -05:00
Dmitry VerkhoturovandGitHub a0879b2336 Measure the iframe reveal budgets from inside the page (#2189)
The three reveal tests timed their budgets from before `page.Goto`, so a
slow navigation was spent against a window that belongs to the iframe. In
`TestIframe_StaysHiddenUntilTheDocumentReportsInited` that made the test
vacuous rather than flaky: on a navigation between 2.5 and 5 seconds the
loop bounding the visibility assertion had no budget left, ran zero times,
and the test passed having asserted nothing. Reproduced by delaying the
demo document by three seconds, where the assertion ran 0 times before and
runs 23 after. The timeout test had the mirror of it, with navigation
counting toward the lower bound that exists to catch a shortened fallback.

An init script now records, in the page, when the widget's iframe element
enters the document and when its visibility first flips. `create-iframe.ts`
arms its fallback a moment earlier, on the detached element, so these read
a shade short and every bound is conservative in the same direction.

Both bounds were also wider than the thing they guard. The hidden window
now runs almost to the fallback rather than half of it, and the lower bound
sits just under it rather than at three quarters, which a fallback
shortened to four seconds used to clear.

CI reruns a failing test once rather than failing the build on the first
flake. A browser suite has a floor no amount of care removes, and one flake
failing the build is what stops people trusting the suite. Once rather than
twice, because a rerun stops at the first pass and each further attempt
only widens the window where a real intermittent regression is absorbed.
What needed a rerun is written to a report and uploaded with the traces,
which are kept whether or not the job went green: a run that recovered on
the rerun is exactly the one whose evidence used to be discarded.
2026-08-21 22:05:51 -05:00
Dmitry VerkhoturovandGitHub 7c312da199 Stop the Telegram paragraph rendering with spaces in Japanese and Chinese (#2187)
`telegram-link.tsx` assembles that paragraph from five separate messages
with the anchor and the QR clause in the middle, joining them with a
hardcoded space. Japanese and Chinese do not put spaces between words, so
the assembled sentence carried them mid-clause: `通过 此链接 或扫描二维码
打开 Telegram,` separated a preposition from its object and an adverbial
phrase from its verb. The separator now comes from the locale and is
empty for `ja`, `zh` and `zh-tw`. Korean keeps its spaces, because Korean
uses them, as do Thai's phrase boundaries.

The locale is matched exactly as `loadLocale` matches it. Comparing case
insensitively would have been worse than the bug: `remark_config.locale`
is forwarded verbatim and `loadLocale` is case sensitive, so a
conventional `zh-TW` loads the English catalogue, and a lowercased
comparison would then join English words with nothing between them. The
test covers that case alongside `ja` and `en`, and fails if either the
comparison loosens or the separator stops depending on the locale.

Macedonian labelled the replies feed as comments. `subscribeByRSS.replies`
carried `Коментари`, the same value as `user.comments`, in a catalogue
whose two reply strings are both `Одговори`. That option subscribes to
`/rss/reply?user=`, which `UserReplies` documents as comments replied to
that user, so the feed is replies.

`auth.user-not-found` is removed. It reached every catalogue but could not
render: the only dynamic path to it is `messages[invalidReason]`, and
`invalidReason` comes from `getTokenInvalidReason`, which returns
`expiredToken`, `invalidToken` or null, or from a backend error string,
and the backend emits nothing matching. Catalogues go from 181 keys to
180.
2026-08-21 19:17:49 -05:00
Dmitry VerkhoturovandGitHub a5b2fe3cfc Consolidate the frontend toolchain onto babel, and ship one bundle (#2178)
Four upgrades that were finished but never merged, the compiler collapse
they enable, and the dependency sweep that follows. Direct
devDependencies go from 78 to 60 and dependencies from 10 to 9.

Three were doing the same job: `ts-loader` stripped types in webpack,
`babel-loader` did everything else, and `@swc/jest` repeated both for the
tests with its own copy of the JSX settings. Babel is the one that
survives, because the `data-testid` stripper has no equivalent elsewhere.

`ts-loader` ran `transpileOnly: true`, so it only stripped types, which
`@babel/preset-typescript` does; `fork-ts-checker-webpack-plugin` was
already what type-checks. Jest runs `babel-jest` against the same
`.babelrc.js` the bundle uses, passed as `configFile` because a
file-relative babel config does not reach the `node_modules` packages in
`transformIgnorePatterns`, and `jest.config.mjs` is plain ESM because a
`.ts` config is compiled against `tsconfig.json`, whose
`verbatimModuleSyntax` rejects ESM syntax in a file the package has not
declared as a module.

That removes `ts-loader`, `@swc/jest` and `@swc/core`. The last was
pinned to 1.2.205 from 2022 with no way forward, because newer builds
emit non-configurable exports and break `jest.spyOn` across 13 suites.

Babel compiles a file at a time with no type information, so it cannot
tell a type-only import from a real one and keeps the module. One line,
`import { boundActions } from './connected-comment'`, pulled the whole
redux store into `last-comments.mjs` and doubled it. `verbatimModuleSyntax`
and `@typescript-eslint/consistent-type-imports` mark them properly; the
statement has to be a separate `import type`, since verbatim semantics
keep an inline `import { type X }` and load the module anyway.

The legacy and modern compilations produced the same bytes. Both read the
same browserslist query, `defaults, not IE 11, not samsung 12` resolves to
chrome 109 and up, and nothing in the source needs transforming for that
set, so 28 of the 29 output pairs were byte-identical.

That made the module/nomodule switch worse than redundant: it served the
`.js` file to browsers with no ES module support, and those files carried
`??`, `?.` and class fields, so the fallback handed its own audience a
syntax error. There is now one bundle, always loaded as a module, in the
five templates and in the seven `site/` documents integrators copy from.
A production build emits 29 files rather than 58, in about 3 seconds
rather than 17. Two of those documents did not work at all beforehand:
the SPA snippet could not parse, and the subdomain example had an
unterminated string.

`@babel/core` 8 declares `^22.18 || >=24.11` and `size-limit` 13 declares
`^22.18 || ^24 || >=26`, so 20 was below the floor of two things installed
here; pnpm only warns, which is why every build passed. All seven places
the frontend pins it move together. `site/` is untouched: it builds with
yarn and eleventy and installs neither.

`eslint --print-config` before and after gives 173 active rules on an
application file against 172, and 172 on a spec file and a plain JS file
against 171. What is gone is three `flowtype` rules with no Flow here,
`no-new-object` and `no-new-symbol` whose upstream replacements are on,
`react/forbid-foreign-prop-types` with no propTypes anywhere, and, on TS
only, `no-useless-constructor`, whose typescript-eslint version is on at
error. `@babel/core` is pinned to 8 across the workspace because
`@jest/transform` and `istanbul-lib-instrument` depend on 7 outright; a
second scoped override holds `eslint-config-preact` on 7, since its
`@babel/eslint-parser` loads babel 7 syntax plugins.

`fast-async` rewrote every async function into nodent promise chains,
calls babel's `transform` synchronously, which babel 8 removed, and every
browser in the target list runs async natively. `prefresh` blew its stack
on `createContext` under babel 8 with no newer release to move to, which
compiled `intl.tsx` and `store/context.tsx` into throwing stubs, so
`pnpm dev:app` could not run the widget at all. `core-js` is not injected
now that `useBuiltIns` is gone, `postcss-custom-properties` was reached
directly although nothing declared it and resolved only through pnpm's
private hoist directory, and `cssnano` ran in both postcss chains although
`CssMinimizerPlugin` already uses it.

`pnpm lint`, `pnpm test` and `pnpm build` now work from `frontend/` as
`CLAUDE.md` and the contributing guide have always said they do; the
workspace root defined none of them.
2026-08-21 19:13:25 -05:00
Dmitry VerkhoturovandUmputun 7ee3a0da48 Tidy the example module for the testify bump
Dependabot updates `backend/` only, so the example module that replaces
it with `../../` keeps the old versions as indirect entries and the
`test examples` job fails with `go: updates to go.mod needed`.

Beyond testify itself this picks up the yaml module move, from
`gopkg.in/yaml.v3` to `go.yaml.in/yaml/v3`, and drops two indirect
entries nothing needs any more.
2026-08-21 18:44:10 -05:00
dependabot[bot]andUmputun 4aaba0fb61 chore(deps): bump github.com/stretchr/testify
Bumps the go-modules-updates group in /backend with 1 update: [github.com/stretchr/testify](https://github.com/stretchr/testify).


Updates `github.com/stretchr/testify` from 1.12.0 to 1.12.1
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 18:44:10 -05:00
Dmitry VerkhoturovandGitHub fb7b6c2cdd Serve the build-independent web assets from the backend (#2181)
* Serve the build-independent web assets from the backend

`privacy.html`, `markdown-help.html` and the `400x400.jpeg` it embeds carry
no template variable, link no script or stylesheet, and are imported by
nothing in the widget. They now live in `backend/app/webassets/assets`,
embedded there, and are served under `/web` alongside the frontend build.

`/web` reads the frontend build first and falls back to them, which is what
lets an operator replace one by dropping a file into `--web-root`. That is
what `privacy.html` needs: it describes remark42.com, while the
authorization guide tells operators to hand its URL to Google and Facebook
as their own application's privacy policy.

Only a missing file falls through. An unreadable file in the web root keeps
reporting as unreadable rather than being silently replaced by the embedded
copy, and a name the filesystem rejects reports as missing rather than as a
server error, both matching what `http.Dir` did.

The dev server serves the same directory, so the Markdown help link in the
comment form resolves on the dev port as well as in production.

The two pages are served as they are written. `markdown-help.html` was
minified before, and its formatted inline stylesheet is most of its 8.5 kB;
that is 2.4 kB more over the wire, behind the hour-long cache header the
file server already sets.

Drops `copy-webpack-plugin`, which had no other pattern, and the stylelint
entries that only ever matched these files.

* Make pnpm dev:app start again

The dev server has been failing to start on two counts, so the flow the
contributing guide documents does not run at all.

`webpack-cli` 4 drives `webpack-dev-server` 5 through the argument order
of an older major, handing it the compiler where it expects the options
object. It rejects that against its schema and exits, complaining about an
unknown `_assetEmittingPreviousFiles` property, which is a field of the
compiler. `webpack-cli` 7 is the release that declares
`webpack-dev-server` 5 as a peer.

Past that, `http-proxy-middleware` resolves to 4.1.1, which no longer
accepts the two-argument call `webpack-dev-server` makes, so the `/api`
and `/auth` proxies throw on startup. It is pulled in by the security
override for CVE-2025-32996, the only override in the file with no upper
bound: `>=2.0.10` matches every later major. Bounding it to the 2.x line
keeps the fix and the API `webpack-dev-server` calls.

With both in place `pnpm dev:app` serves the widget and the pages under
`/web` on port 9000.
2026-08-21 18:43:06 -05:00
dependabot[bot]andUmputun 123b9328d9 chore(deps): bump alpine in /site in the site-image-updates group
Bumps the site-image-updates group in /site with 1 update: alpine.


Updates `alpine` from 3.22 to 3.24

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.24'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: site-image-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 18:20:34 -05:00
dependabot[bot]andUmputun 2bfad021e3 chore(deps): bump github.com/mxschmitt/playwright-go
Bumps the go-modules-updates group in /e2e with 1 update: [github.com/mxschmitt/playwright-go](https://github.com/mxschmitt/playwright-go).


Updates `github.com/mxschmitt/playwright-go` from 0.6201.0 to 0.6201.1
- [Release notes](https://github.com/mxschmitt/playwright-go/releases)
- [Commits](https://github.com/mxschmitt/playwright-go/compare/v0.6201.0...v0.6201.1)

---
updated-dependencies:
- dependency-name: github.com/mxschmitt/playwright-go
  dependency-version: 0.6201.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 18:20:27 -05:00
Dmitry VerkhoturovandGitHub 4c9ef37cf1 Move the site from eleventy to hugo (#2179)
* Move the site from eleventy to hugo

The site is built by a single static binary. No node, no package manager,
no lockfile, and the toolchain it needed is gone: eleventy, tailwind,
postcss, markdown-it and its three plugins, date-fns, prism, npm-run-all,
cross-env and html-minifier-terser.

Hugo covers most of that itself. Chroma replaces prism, goldmark replaces
markdown-it, `--minify` replaces html-minifier-terser, and fingerprinted
asset URLs replace the cache-busting `version` shortcode that stamped
`Date.now()` into every stylesheet link.

`assets/styles.css` is hand-written, since tailwind was the only reason
left to keep a package manager. The palette and the light and dark values
are custom properties at the top of the file; the minified stylesheet is
15 kB against tailwind's 46 kB, and the whole build 1.0 MB against 1.2 MB.
It was matched to the old one by comparing computed styles rather than by
eye, which is how the heading weights and line heights, the list marker
colour, and the home page heading and sign-off were caught: the last of
those had been carried by tailwind utilities written into the markup.

The `::: note` container becomes a `note` shortcode taking the emoji to
show. Its closer needs a blank line after it, because a shortcode is not
a block rule the way `markdown-it-container` was, and without one goldmark
keeps the callout inside the open paragraph. The `overflow-x` wrapper
around tables and the heading anchors are goldmark render hooks.

Syntax guessing is off. Chroma detected a systemd unit file as gdscript
and a chat transcript as mysql, and colouring a snippet as the wrong
language is worse than not colouring it. The two chroma themes are scoped
to opposite sides of the theme switch rather than layered, because they do
not declare the same properties on the same tokens: github gives Error a
background github-dark never overrides, and styles Punctuation where
github-dark leaves it alone. Layered, either leaves a light value applying
on a dark page.

`[frontmatter] lastmod` resolves through git, then front matter, then file
modification time. Without that chain `.Lastmod` falls back to `.Date`,
which is zero when a page carries no date, and every page reads
`Jan 01, 0001`. `enableGitInfo` is off because the image build context is
`site/` alone, where hugo fails hard rather than degrading;
`HUGO_ENABLEGITINFO=true` gives real per-page commit dates locally.

Three fixes fall out of the move rather than being sought:

- `/docs/` redirected nowhere. The stub was a markdown file whose
  permalink was a template expression while `markdownTemplateEngine` was
  false, so it never rendered and the URL 404'd. It is an alias now
- `/docs/contributing/` pointed at `/docs/contributing/development/`,
  which has never existed. It points at the backend page
- the 404 page was built to `/404/` and nothing served it. Hugo writes it
  to `/404.html` and reproxy is told to use it

The mobile documentation menu is a checkbox and label. `visibility: hidden`
on the checkbox, which is what the old `invisible` utility set, takes it
out of the tab order, and a label is not focusable on its own, so the menu
could not be opened from the keyboard at all. The checkbox is clipped
rather than hidden, and its label shows a focus ring.

Content is unchanged. Every code block on every page is byte-identical to
the eleventy output; the only prose difference is that two example values,
`mysite.com` and a quoted `https://demo.remark42.com`, are no longer
turned into links, goldmark's linkify being narrower than markdown-it's.

`backend/README.md` and `frontend/apps/remark42/README.md` are symlinks
into the docs tree and follow it to `site/content/`, as does the path
`release.yml` watches. `frontend/CLAUDE.md` described the site as a node
and yarn project in four places.

* Keep the heading anchors markdown-it generated

Goldmark strips punctuation markdown-it kept, so 22 headings holding a
dot, slash, apostrophe, question mark, bracket or em dash would take a new
id and any link into one from outside the repository would stop resolving.

Those headings carry their previous id as well, as an empty target emitted
ahead of the heading by the render hook, from a map of content path to old
anchor in `data/anchor_aliases.json`. The map was built by matching
heading text between the two builds rather than by position, so it
survives a heading being added or moved.

The hook rather than markdown, because goldmark's `{#id}` attribute syntax
cannot express these: it accepts dots, apostrophes and em dashes but
treats a slash, a question mark, a bracket or a percent sign as heading
text, which is 11 of the 22. The ids are stored percent-decoded, since a
browser decodes a fragment before matching, so `#children%E2%80%99s-privacy`
finds `children’s-privacy`. Verified by navigating to the awkward ones
against the built image and measuring where the page settles: each lands
112px down, which is the header offset the target carries.

Three pages carried no title, so the docs template rendered an empty `<h1>`
above the heading their markdown already had. They take their titles from
that heading text, so neither the wording nor its anchor changes, and the
template's `<h1>` carries an id. One in-page link pointed at an anchor
goldmark no longer generates.

The heading render hook emits no permalink anchor. The one it replaced was
an empty `<a href>` with `pointer-events: none`, so it could not be
clicked, and its only job was a `::before` spacer that `scroll-margin-top`
on the heading already does. Being an `<a href>` it stayed in the tab
order, so every heading was an unexplained keyboard stop: eight on the
installation page alone. Fragment navigation still lands 112px down, clear
of the fixed header.

* Harden the site image build and its CI

The architecture guard could not fire. `${TARGETARCH:-amd64}` defaulted
before the `unsupported arch` branch was reachable, so a build without
buildkit put an amd64 hugo inside an aarch64 image and ran only because
Docker Desktop emulates it. Reproduced with `--build-arg TARGETARCH=`:
`/etc/apk/arch` reported aarch64 and `hugo version` linux/amd64. An empty
value is an error now. `Dockerfile.dev` had the same defect and no smoke
step to catch it, so it would have failed at `compose up`.

The hugo tarball is verified against the release's own `checksums.txt`,
and the match is asserted present before it is used: piping grep straight
into `sha256sum -c` left the guarantee resting on what the checker does
with empty input. Busybox exits 1 there, so it did fail closed, but
nothing in the line said so. Verified against a checksums file that does
not list the tarball: the build stops before the install.

Hugo exits 0 on an empty content tree and emits a two-page shell, which
would have been copied, pushed and deployed. The build asserts the home
page and a docs page exist.

`site/**` pull requests were never built. The only building job is gated
on `github.ref == 'refs/heads/master'`, so on a pull request every job
skipped and rendered in the checks list the same way a pass does, and the
image was first built on the run that also deploys it. A `validate` job
builds it with `push: false`, needing no secrets so it works on a fork.

`.github/dependabot.yml` watched `/site` for npm packages that are gone.
That entry is a docker one, which tracks the alpine base. It does not
track the hugo pin and cannot: the docker ecosystem reads `FROM`
references, and `ARG HUGO_VERSION` is a bare string in a download URL, so
that one is a manual bump and `site/README.md` says so.

`Dockerfile.dev` carries a `COPY`, so the dev image works without the
compose bind mount, and compose runs as the invoking user rather than
root, which on linux left root-owned `public/` and `resources/` in the
checkout.

Recorded in the backlog: `master` has `required_status_checks` off with an
empty check list, so the new job surfaces a red X and does not block a
merge. That is a settings decision rather than a code fix.
2026-08-21 18:05:56 -05:00
Dmitry VerkhoturovandGitHub ff77f41a3a Move the e2e suite to Go and playwright-go (#2180)
* Move the e2e suite to Go and playwright-go

The seven playwright tests in `frontend/e2e` become twenty in `e2e/`, a
separate Go module driving the same browsers through playwright-go. The
npm project, its lockfile entries, its prettier config and
`Dockerfile.e2e` go with it, leaving `frontend/` a single-member
workspace.

The suite covers posting with markdown, replying and the nesting that
implies, editing inside the deadline and the backend refusing one outside
it, deleting, voting with the optimistic score observed mid-flight and
rolled back on failure, changing the sort, collapse persistence across a
reload, dev, anonymous and email sign-in end to end, the profile iframe,
and the two scripts that render into the host page rather than the
widget's own frame.

The rendering tests run in chromium, firefox and webkit. The rest sign in,
sign-in needs the dev oauth2 provider, and reaching that by name from the
host is chromium-only, so they run there alone.

`compose-e2e-test.yml` runs remark42, a second instance with a short edit
window so that path does not need a five-minute test, and mailpit, which
catches the email verification message the suite reads back. Everything
binds to the loopback interface: the stack holds a known secret and an
admin shared id, and `go test` can start it unattended. The tests run on
the host rather than in a container.

Three settings there exist for the tests rather than for realism.
`REMARK_URL` uses a hostname because the dev oauth2 server binds whatever
host it reads out of it, and a loopback bind inside a container cannot be
published. `UPDATE_LIMIT` is raised because the default of 0.5/sec rejects
any test posting twice in a row. The suite also paces its own `/auth/`
calls, which are capped at 2/sec by a bare literal in `rest.go` rather
than by a setting.

Each test gets its own comment thread from a query string on the demo
page, so nothing has to reset the database between runs.

CI gains a vet and lint job for the module, since the build tag keeps it
out of a plain `go test ./...`, and uploads a browser trace for any test
that fails.

`e2e/README.md` carries the rest: how to run it, what the stack is for,
and the widget behaviour the assertions have to work around.

* Update golangci-lint to 2.13.1 in the backend workflow

The pin sat three minors behind what the linter installs locally, so CI
checked the backend with an older set of rules than anyone running it by
hand. 2.10.1 also fetches its config schema over the network on every
`config verify`, which is a failure mode with no bearing on the code.

Both targets are clean on 2.13.1, `backend/app` and the memory_store
example.
2026-08-21 17:53:12 -05:00
Dmitry VerkhoturovandGitHub 1bb002348a Complete and correct every translation catalogue (#2177)
* Fix wrong and missing translations across 17 locales

`errors.8` is `ErrReadOnly` (`backend/app/rest/httperrors.go:29`), but 13
catalogues carried a copy of `errors.7`, which is `ErrUserBlocked`. A
reader who simply hit a read-only thread was told they had been blocked,
in Belarusian, Bulgarian, Brazilian Portuguese, German, Finnish, French,
Japanese, Polish, Russian, Turkish, Ukrainian, Vietnamese and Simplified
Chinese. Each now says the page is read-only, in the terminology that
catalogue already uses for its read-only badge.

Czech had an off-by-one: `errors.19`, restricted words, carried the text
of `errors.18`, file not found, and `errors.18` was left in English. So a
comment caught by the word filter reported a missing file. Both rewritten.

Also corrected, all of the same class:

- `de` `vote.downvote` had a leading space
- `mk` had dropped `{shortcut}` from the bold, italic and link tooltips,
  losing the keyboard hints
- `fi` `comment.pin` was "Sitoo", which means "it binds", and `comment.unpin`
  followed from it; `errors.forbidden` was misspelled "Kieletty."
- `be` `errors.failed-fetch` ended in a stray "r"
- `zh-tw` `authPanel.read-only` wrote 唯獨 for 唯讀
- `ja` `errors.conflict` used 相衝, which is not Japanese usage
- `it` `auth.symbols-restriction` misspelled "numberi" and used "username"
  where `auth.username` now says "Nome utente"

Nine Finnish strings, `it` `auth.username` and `zh-tw` `comment.time` were
left in English; `comment.time` is a format string and now matches the
other CJK locales at `{day} {time}`.

No English source string changes, and every runtime placeholder is
preserved. Reviewed by two independent passes, which between them reworded
five of these and found four of the pre-existing defects above.

* Complete every translation catalogue

No locale carries English text any more, and the Telegram authorisation
paragraph now reads as a sentence in all 23 of them.

That paragraph is assembled in `telegram-link.tsx` from five separate
keys with the link and the QR clause in the middle, so a catalogue that
translates each key in isolation produces word salad in any language
whose verb does not sit where English puts it. Japanese rendered as
"テレグラムを開く リンクで または QR コードをスキャン そこで..." and Korean, Persian,
Traditional Chinese and Czech had the same break. Each of those now
splits the sentence at the point its own grammar wants, and every
catalogue was checked by rendering both the wide and the narrow layout,
since the QR clause only appears above 768px.

Ten catalogues also had `auth.telegram-link`, which is the anchor text
and reads "by the link", left as "Telegram bot" from an older source
wording, so the paragraph named the bot twice and never said what the
link was for. Six of the block's keys had never been translated at all
in the 16 locales that ship it, and `auth.telegram-message-1/2/3` were
English in nine. None of these were visible to a check for "value equals
the English string", because none of them equalled it.

Two of the six untranslated keys are word for word the English of their
`subscribeByEmail` siblings, so each catalogue's own existing wording was
reused rather than a second phrasing invented for the same sentence.

Quotation marks in that paragraph now follow each language rather than
the English source: «» for be, ua, ru, fa, fr and ar, „“ for bg, cs, de
and mk, „” for pl and ro, ”” for fi, 「」 for ja and zh-tw.

The button the paragraph tells the reader to press is Telegram's own, and
Telegram ships no interface translation for Japanese, Thai or Vietnamese,
so those three now name it the way Russian and Traditional Chinese
already did, with the Latin label alongside the translated one.

Also swept and fixed: Vietnamese "Bằng đã huỷ đăng kí" for "Bạn", Spanish
"ó" for "o", Thai "คลิ๊ก" for "คลิก", an unclosed quotation mark in Arabic
`commentForm.upload-file-fail`, German alternating between tippen and
klicken for the same action, Czech infinitive "Otevřít" where the rest of
the paragraph is imperative, Macedonian "СО ЛИНК" in caps, French
"Sélectionner" on a button the text calls "Vérifier", a missing space
after a full stop in `ua` `errors.9`, and double spaces in `mk`, `pl` and
`vi`.

Left identical to English on purpose, because the word is the same in
that language: `RSS` and `Telegram` everywhere, "Email" in be, it, pl,
ro, ua and vi, "Site" in bp, fr and ro, and "Conflict." in ro. German and
Turkish do not use bare "Site" and say "Website" and "Web sitesi".
2026-08-21 17:42:41 -05:00
Dmitry VerkhoturovandGitHub fc4e10573c Replace react-intl and remove React from the widget (#2176)
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`.
2026-08-21 02:30:26 -05:00
Dmitry VerkhoturovandGitHub a91e322d5c Replace react-redux with a preact context binding (#2175)
* 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.
2026-08-21 00:47:08 -05:00
Dmitry VerkhoturovandUmputun 931f2db4e3 Drop turbo
CI never invoked it, and after #2172 removed the four api scripts its
only remaining job was orchestrating one script in one package.
2026-08-20 18:12:34 -05:00
Dmitry VerkhoturovandGitHub b8f6dc5f91 Require node 20 and record every place the version is pinned (#2168)
* 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.
2026-08-20 18:12:30 -05:00
Dmitry VerkhoturovandGitHub b03dc366f9 Update preact to 10.29.8 (#2163)
* 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.
2026-08-20 02:31:43 -05:00
Umputun 36062de0e7 docs: drop the npm deprecation backlog item
remark42 deferred work belongs in the pull request response where paskal and
akellbl4 will see it, not in a file.
2026-08-20 02:31:15 -05:00
Umputun 90766d6637 ci: add umputun as a frontend code owner
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.
2026-08-20 02:31:15 -05:00
Umputun a1dbb2cb92 ci: run frontend checks on any frontend change
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.
2026-08-19 23:53:52 -05:00
Dmitry VerkhoturovandGitHub d370b78613 Drop the @remark42/api package (#2172)
* 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.
2026-08-19 23:28:52 -05:00
Umputun 439ccfa83c docs: note @remark42/api is still published and undeprecated on npm 2026-08-19 23:19:15 -05:00
Dmitry VerkhoturovandUmputun 29627f4bf0 Raise site resolution floors to clear remaining advisories
Both floors are bounded on the upper side, as an open-ended resolution
lets yarn cross a major version.
2026-08-19 03:39:33 -05:00
Dmitry VerkhoturovandUmputun 164eb89c60 Raise pnpm override floors to clear all frontend advisories
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.
2026-08-19 03:39:25 -05:00
Dmitry VerkhoturovandUmputun 09110c792f Bump backend Go modules to latest
Updates every backend dependency with a newer release available, and
tidies the example module alongside as any change to backend/go.mod
requires.
2026-08-19 03:39:11 -05:00
3f5b3cdd98 feat: add configurable SMTP HELO hostname (#2146)
* 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>
2026-08-19 02:52:39 -05:00
dependabot[bot]andUmputun 43fccf3bc9 chore(deps): bump the github-actions-updates group across 1 directory with 3 updates
Bumps the github-actions-updates group with 3 updates in the / directory: [actions/setup-go](https://github.com/actions/setup-go), [pnpm/action-setup](https://github.com/pnpm/action-setup) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-go` from 6 to 7
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.9...v6.0.10)

Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-19 00:49:19 -05:00
Dmitry VerkhoturovandGitHub 1f34984dab Bump go-pkgz/rest to v1.24.0 and opt in to wildcard origins with credentials (#2157)
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.
2026-08-19 00:33:13 -05:00
Dmitry VerkhoturovandGitHub 455d770899 ci: track the latest Go 1.25 patch instead of pinning one (#2156)
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.
2026-08-19 00:31:22 -05:00
Dmitry VerkhoturovandUmputun bf67c251c5 docs(claude): require an example tidy on any go.mod change
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.
2026-08-18 20:27:21 -05:00
Umputun cebba4cee4 docs: add backlog item for the macOS api test deadlock 2026-08-18 20:24:30 -05:00
Umputun 35a389cb75 fix: bump golang.org/x/image to v0.45.0 for GO-2026-6222
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.
2026-08-18 20:24:30 -05:00
Umputun a725d990ed docs: add backlog item for the CORS wildcard credentials opt-in 2026-08-18 18:58:07 -05:00
Dmitry VerkhoturovandUmputun fdfce6495c Remove the widget body padding and the surplus reported height
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.
2026-08-18 18:46:05 -05:00
Dmitry VerkhoturovandUmputun 8801903d01 Derive host from the page URL on self-served pages
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.
2026-08-18 18:45:59 -05:00
Dmitry VerkhoturovandUmputun 8bcfd9e456 Close the login dropdown only on a genuine outside click
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.
2026-08-18 18:45:54 -05:00
Dmitry VerkhoturovandUmputun 5b37a583ce Stop Dependabot npm updates, including security updates
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.
2026-08-18 18:45:49 -05:00
Dmitry VerkhoturovandUmputun 29b5f88a1c Document Microsoft supported account types, drop deprecated Twitter example
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.
2026-08-18 18:24:36 -05:00
Umputun 287aef4dfb docs: add backlog items for site PR validation and frontend js-yaml overrides
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/.
2026-08-11 10:54:15 -05:00
dependabot[bot]andUmputun d06aa6771c chore(deps): bump js-yaml from 3.15.0 to 3.15.1 in /site
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.15.0 to 3.15.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.15.0...3.15.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.15.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 10:36:46 -05:00
Dmitry VerkhoturovandUmputun a54d2d2756 Cache per-user flag lookups in comment listings
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.
2026-07-11 02:29:17 -05:00
Umputun e575066ea9 revert(ci): restore two-build docker.yml publish
#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.
2026-07-11 02:18:00 -05:00
Dmitry VerkhoturovandUmputun 2544d80f98 Return 400 for export of an unknown site
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.
2026-07-11 02:10:42 -05:00
Dmitry VerkhoturovandUmputun 5c0798fe10 docs(claude): document milestone + issue-label conventions
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).
2026-07-11 02:08:40 -05:00
Umputun 76d0cc2cf6 fix(ci): pin go-version to 1.25.12 for GO-2026-5856
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.
2026-07-11 02:05:58 -05:00
Dmitry VerkhoturovandUmputun 51b6a7e890 Modernise Docker build workflows
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.
2026-07-11 02:00:52 -05:00
Dmitry VerkhoturovandUmputun 8c5e82bd16 Drop armv7 build target and remove dead golangci settings
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.
2026-07-11 01:57:54 -05:00
Dmitry VerkhoturovandUmputun 95f59213e5 Make notify drop tests deterministic
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".
2026-07-11 01:56:10 -05:00
Dmitry VerkhoturovandUmputun 503f5cacb0 Add workflow to validate compose files
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.
2026-07-11 01:54:21 -05:00
Dmitry VerkhoturovandUmputun d1f8cf412b Add govulncheck scan to backend CI
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.
2026-07-11 01:52:13 -05:00
Dmitry VerkhoturovandUmputun db9d8703ef Fix flaky TestPublic_FindCommentsCtrl_ConsistentCount
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.
2026-07-11 01:50:36 -05:00
Dmitry VerkhoturovandUmputun 8f61ec691b Fix comment-tree pagination under-filling exact-fit subtrees
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.
2026-07-11 01:48:45 -05:00
Dmitry VerkhoturovandUmputun 07f6b9a0a0 Remove obsolete version key from compose files
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.
2026-07-11 01:33:06 -05:00
Dmitry VerkhoturovandUmputun 98e4f03091 Run backend CI on PR updates and freeze frontend lockfile installs
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.
2026-07-11 01:31:14 -05:00
Dmitry VerkhoturovandUmputun f8f2becb4b Fix dropped notification errors and switch to errors.Join
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.
2026-07-11 01:28:31 -05:00
Umputun 6e7820d2b7 fix(frontend): remove white flash on comments iframe load
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.
2026-07-09 22:10:48 -05:00
Umputun 3e63d72852 fix(ci): build docker images on frontend-only master pushes
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.
2026-07-09 20:03:26 -05:00
Dmitry VerkhoturovandUmputun a8dd527c45 Fix comments iframe collapsing to preloader height on load
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.
2026-07-09 17:28:40 -05:00
Dmitry VerkhoturovandUmputun e62b3c830d fix(trusted-proxy): warn on catch-all, cover more cases, trim wording
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
2026-07-09 15:05:05 -05:00
Dmitry VerkhoturovandUmputun b1502801fa fix: add --trusted-proxy to gate client-IP forwarding headers
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.
2026-07-05 17:47:19 -05:00
Dmitry VerkhoturovandUmputun 2e3a680ca4 fix(deleteme): surface real avatar-store errors, tolerate only not-found
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.
2026-07-05 17:28:01 -05:00
Dmitry VerkhoturovandUmputun d8b7f7530c fix: remove user avatar on deleteme request
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.
2026-07-03 15:40:31 -05:00
Dmitry VerkhoturovandUmputun b33025a76f feat(api): adopt enforcing rest.Timeout, drop local cooperative timeout
go-pkgz/rest v1.22.0 ships an enforcing Timeout middleware (net/http.TimeoutHandler
style): it runs the handler with a deadline and returns 504 at the deadline even if
the handler ignores the context - unlike the local cooperative timeout, which only
cancelled the context and never actually stopped a stuck handler.

Replace the local timeout with rest.Timeout on every route with a bounded response.
The streaming and long-polling routes are deliberately left without it, since the
enforcing timeout buffers the whole response in memory and aborts at the deadline:
- GET /api/v1/userdata and GET /api/v1/admin/export stream gzipped exports
- GET /api/v1/admin/wait long-polls for up to 15m
- POST /api/v1/admin/import[/form] and /remap ingest large uploads

Delete the local timeout middleware and its test; the enforcing behaviour is covered
by go-pkgz/rest. TestRouteTimeout locks the enforcing-vs-exempt contract in this build.
2026-07-03 15:40:10 -05:00
Dmitry VerkhoturovandUmputun c48254a994 chore(deps): bump go-pkgz/rest to v1.22.0, drop local CORS Vary workaround
v1.22.0 includes the preflight Vary fix (https://github.com/go-pkgz/rest/pull/44):
rest.CORS now adds Vary: Access-Control-Request-Method and
Access-Control-Request-Headers on preflight itself, making the local wrapper
that added them redundant. corsMiddleware now returns rest.CORS directly;
TestCorsMiddleware still asserts those preflight Vary headers, now supplied
upstream.

Also tidies the _example/memory_store module for the new version.
2026-07-03 15:40:10 -05:00
Dmitry VerkhoturovandUmputun 3fc5d6b970 fix: make user deletion idempotent for users without comments
deleteUser now succeeds for a user who has no comments (e.g. one who only logged
in) instead of failing on the missing per-user bucket. In hard mode the per-user
bucket is deleted, tolerating bbolt's ErrBucketNotFound so a bucket left behind by
an earlier partial removal is still removed; the comment-deletion failure path now
wraps the actual error.

Because the engine cannot distinguish a valid login-only user from a never-existed
one, deletion is idempotent: /admin/deleteme returns 200 for an unknown (but validly
signed) token rather than 400. The deleteme test is updated to this contract, engine
tests cover hard and soft deletion of login-only and unknown users, and the API docs
note the idempotent behaviour.
2026-07-01 15:05:27 -05:00
Fredrik AppelrosandUmputun 380aa3c828 Allow deleteUser to be called on users with no comments 2026-07-01 15:05:27 -05:00
Fredrik AppelrosandUmputun b6bc8ba675 Fix error handling in deleteUser function to return the correct error when deleting a user bucket. 2026-07-01 15:05:27 -05:00
Dmitry VerkhoturovandGitHub c5121fd402 refactor(api): replace go-chi/chi router with go-pkgz/routegroup (#2103)
Migrate the REST router off go-chi/chi onto go-pkgz/routegroup (backed by the
stdlib http.ServeMux), removing the last use of go-chi from the backend:

- rest.go routes() builds the tree with routegroup (Mount/Group/Route/With) and
  net/http method+path patterns instead of chi's Get/Post/Route/Mount helpers
- chi.URLParam(...) -> r.PathValue(...) in the admin, public and private handlers
- rest_public_test.go loadPictureCtrl test uses routegroup + http.ServeMux
- rest_test.go: add TestRest_FileServerStaticAssets (bare /web -> /web/ redirect,
  cache headers, 404, directory-listing block) and update the path-traversal test
  for ServeMux normalising a literal ".." (encoded traversal is still rejected
  by the handler)
- drop go-chi/chi from go.mod, go.sum and vendor; update the CLAUDE.md reference
2026-07-01 15:04:34 -05:00
Dmitry VerkhoturovandUmputun fff9127976 fix: correct no-providers message grammar, translate it, and cover both branches
Reword "May be" to "Maybe" in the auth.no-providers message and run
translation:generate to register the key in every locale dictionary, then
replace the English placeholders with proper translations for each locale.
Add a test asserting the error is hidden when providers are configured.
2026-06-30 18:23:21 -05:00
Eugene OrlovandUmputun 406df022ba fix: ui error when no auth providers configured 2026-06-30 18:23:21 -05:00
Dmitry VerkhoturovandUmputun 6840a46ac9 Replace go-chi/cors with go-pkgz/rest CORS
Swap the go-chi/cors middleware for rest.CORS (already a dependency),
removing the go-chi/cors module entirely. Behaviour-preserving:
- with AllowedOrigins "*" and credentials enabled, both reflect the request
  Origin into Access-Control-Allow-Origin (a literal "*" is invalid with
  credentials)
- preflight responses also vary on Access-Control-Request-Method/-Headers, not
  just Origin, matching go-chi/cors so caches don't reuse a preflight response
  across different requests

Extract the config into corsMiddleware() in middleware.go and add
TestCorsMiddleware covering origin reflection, credentials, preflight
methods/headers/max-age/Vary, and the no-Origin case. go-chi/cors dropped
from go.mod; the chi router stays until the router migration. go test -race,
vet, golangci-lint, govulncheck clean.
2026-06-30 17:40:03 -05:00
Dmitry VerkhoturovandUmputun 6a50ffd88a Use stdlib http.ServeMux instead of chi in cleanup_test
The cleanup command test builds a self-contained mock HTTP server with
only static routes (and {id} patterns read via r.URL.Path, not URLParam),
so chi.NewRouter is unnecessary — http.NewServeMux (Go 1.22 routing) covers
it. Independent of the main router; drops the chi import from app/cmd.

go test -race and golangci-lint clean.
2026-06-30 17:39:37 -05:00
Dmitry VerkhoturovandUmputun f7dbdae26c Consolidate request middlewares into middleware.go
Pure relocation, no behaviour change: gather all request-scoped middlewares
and their tests into dedicated files instead of scattering them across
rest.go and ssl.go.

  funcs -> app/rest/api/middleware.go:
    timeout (from ssl.go); rejectAnonUser, matchSiteID, cacheControl,
    apiCSPMiddleware, securityHeadersMiddleware, subscribersOnly,
    validEmailAuth, rateLimiter (from rest.go)
  tests -> app/rest/api/middleware_test.go:
    TestTimeout (from ssl_test.go); TestRest_rejectAnonUser,
    TestRest_cacheControl, TestRest_apiCSP, TestRest_securityHeaders,
    TestRest_subscribersOnly, Test_validEmailAuth, TestRest_matchSiteID
    (from rest_test.go)

go test -race, vet, golangci-lint and govulncheck clean; example builds.
2026-06-30 17:15:39 -05:00
Dmitry VerkhoturovandUmputun f4b236c66a Replace chi middleware.Timeout with the timeout helper, drop chi/middleware
middleware.Timeout was the last use of go-chi/chi/v5/middleware (RealIP, the
other user, landed in #2099). Swap it for the local timeout helper (context
deadline + 504 on deadline, matching chi exactly; covered by TestTimeout),
which removes the go-chi/chi/v5/middleware package from the vendor tree.

The go-chi/chi module stays in go.mod — the router (chi.NewRouter etc.) still
uses it, so go.mod only shrinks after the router migration. Build, vet, race
tests and golangci-lint clean.
2026-06-30 17:15:04 -05:00
Dmitry VerkhoturovandUmputun 17365f4304 Replace chi middleware.RealIP with rest.RealIP on the main router
Behaviour-preserving swap: rest.RealIP sets r.RemoteAddr from
X-Real-IP / X-Forwarded-For like chi's middleware.RealIP, removing chi from
the RealIP path without changing the trust model (GHSA-56x6-q882-mf27 stays
present, to be fixed separately). chi/middleware stays imported for Timeout;
whichever of this PR and the Timeout PR (#2097) merges last drops the import.
Drop-in to a tested rest middleware; covered by existing api router tests.
2026-06-30 16:38:12 -05:00
Dmitry VerkhoturovandUmputun 0b6eea68a1 Replace chi middleware.NoCache with rest.NoCache on the main router
go-pkgz/rest.NoCache is borrowed from chi's middleware.NoCache and behaves
identically: same no-cache response headers and the same stripping of
conditional request headers (If-None-Match etc.), which the image-proxy
etag logic relies on. Drop-in swap, chi router left in place.

chi/middleware stays imported for RealIP and Timeout. Build, vet, race
tests and golangci-lint clean.
2026-06-30 16:27:01 -05:00
Dmitry VerkhoturovandUmputun b19e6269c1 Replace chi middleware.Throttle with rest.Throttle on the main router
Drop-in swap of the global concurrency limiter (go-pkgz/rest.Throttle has
the same signature and semantics as chi's middleware.Throttle), with the
chi router left in place. First of the per-middleware swaps that chip away
at go-chi/chi/v5/middleware before the router itself is migrated.

chi/middleware is still imported for RealIP/Timeout/NoCache; build, vet,
race tests and golangci-lint clean.
2026-06-30 16:26:28 -05:00
Dmitry VerkhoturovandUmputun bb6d1450f1 Migrate ssl.go TLS routers from chi to routegroup
First step of the go-chi -> go-pkgz/routegroup migration. The HTTP->HTTPS
redirect and ACME http-01 challenge routers are small, self-contained
http.Handlers separate from the main API router, so they move cleanly:

- chi.NewRouter() -> routegroup.New(http.NewServeMux())
- middleware.Throttle -> rest.Throttle (same concurrency-limit semantics)
- middleware.Timeout -> local timeout helper (context deadline, mirrors chi)
- drop middleware.RealIP: these routers do redirect/challenge only, with no
  per-IP logic, so the spoofable header trust is simply removed here
- return http.Handler instead of chi.Router (callers already take http.Handler)

chi stays a dependency (still used by the main API router); this only removes
its use from ssl.go. go test -race, vet, golangci-lint and govulncheck clean.
2026-06-30 16:26:03 -05:00
dependabot[bot]andUmputun 8318f89dde chore(deps): bump the github-actions-updates group across 1 directory with 4 updates
Bumps the github-actions-updates group with 4 updates in the / directory: [actions/checkout](https://github.com/actions/checkout), [actions/cache](https://github.com/actions/cache), [pnpm/action-setup](https://github.com/pnpm/action-setup) and [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

Updates `actions/cache` from 5 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

Updates `pnpm/action-setup` from 6.0.4 to 6.0.9
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.4...v6.0.9)

Updates `codecov/codecov-action` from 6 to 7
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-updates
- dependency-name: codecov/codecov-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 15:57:05 -05:00
Dmitry VerkhoturovandUmputun 3e18681ca7 Sanitize comment text in email notifications (GHSA-74pc-3r2m-ppx3)
Email notification templates rendered the comment HTML via text/template,
so the store-level UGC sanitizer's permitted <a> and <img> tags reached
the email body verbatim. An authenticated user could plant phishing links
and remote tracking pixels in notification emails sent from the legitimate
remark42 address.

Switch notify to html/template (auto-escaping every non-HTML field) and
add a stricter email-only bluemonday policy that drops <a> and <img> while
keeping basic text formatting; the sanitized comment HTML is passed as
template.HTML. Add regression tests asserting links and images are stripped
while anchor text and formatting survive.
2026-06-30 15:56:36 -05:00
UmputunandGitHub 11d8a978a2 Merge pull request #2094 from umputun/fix/eleventy-outputpath-guard
Guard against falsy outputPath in eleventy htmlmin transform
2026-06-30 15:56:08 -05:00
Dmitry Verkhoturov d7fe27cb97 Guard against falsy outputPath in eleventy htmlmin transform
Eleventy passes a falsy outputPath to transforms for templates rendered
without a written file (e.g. permalink: false); calling .endsWith on it
would throw. Skip minification in that case instead. Pre-existing latent
issue surfaced by Copilot review on #2091.
2026-06-30 20:59:57 +01:00
UmputunandGitHub 7fee12a978 Merge pull request #2091 from umputun/deps/update-frontend
Update frontend and site dependencies to latest, bump pnpm to 10, clear audit alerts
2026-06-30 14:23:48 -05:00
Dmitry Verkhoturov fc3d93c398 Add frontend/CLAUDE.md documenting dependency-update gotchas
Captures what isn't obvious from the diff alone: the ten places a
node/pnpm version is pinned and must move together (including .nvmrc,
which CI never reads and is how the node-16 drift in this PR's first
push went unnoticed), the pnpm-10 layout pins, the msw 1->2 migration,
the deliberately held-back majors, and the abandoned html-minifier
replacement. Written so the next dependency bump doesn't repeat the
same gaps.
2026-06-30 20:11:53 +01:00
Dmitry Verkhoturov 4baf0f4260 Close remaining node/pnpm version drift after the pnpm 10 bump
- frontend/.nvmrc was still pinned to 16, left behind by the node 16->20
  bump everywhere else (Dockerfile, CI matrices). A contributor running
  'nvm use' in frontend/ would land on node 16, which cannot even run
  pnpm 10 (requires node >=18) -- CI never reads .nvmrc, so this was
  invisible to every check.
- pnpm/action-setup 'version: 10' floated the patch release in CI,
  inconsistent with the exact 10.10.0 pin now used in Dockerfile,
  Dockerfile.e2e and packageManager. Pinned all ten occurrences across
  ci-frontend.yml, ci-frontend-api.yml and release.yml to 10.10.0.
2026-06-30 20:10:43 +01:00
Dmitry Verkhoturov b72030114c Address Copilot review feedback on #2091
- Pin pnpm to the exact version (10.10.0) when installing it in the
  production Dockerfile, matching packageManager and Dockerfile.e2e,
  instead of a floating major that can drift the lockfile behaviour.
- Fix mockEndpoint's array header handling in the api test utility:
  append each value instead of joining with a comma, which is how
  multi-value headers (e.g. set-cookie) are actually represented.
- Update apps/remark42's engines to node >=18 / pnpm >=10, matching
  the pnpm 10 requirement instead of the stale node 16 / pnpm 8 range.
2026-06-30 20:05:42 +01:00
Dmitry Verkhoturov 8626e4181f Fix CI for node 20 / pnpm 10: e2e Playwright image and jest arg forwarding
- frontend/Dockerfile.e2e: bump base image to mcr.microsoft.com/playwright:
  v1.61.1-noble to match the Playwright 1.61.1 npm bump (browser revision
  mismatch was failing all e2e specs), and corepack pnpm@8 -> pnpm@10.10.0 to
  match the pnpm bump and the v9 lockfile.
- release.yml validate: pnpm 10 forwards 'test -- --runInBand' literally as
  'jest -- --runInBand' (treated as a path pattern, 0 tests). Drop the extra
  separator: 'pnpm test --runInBand'.
2026-06-30 19:53:10 +01:00
Dmitry Verkhoturov d274724c08 Update site dependencies and clear all yarn audit alerts
yarn audit: 0 vulnerabilities (was 52 findings). Site builds via eleventy +
tailwind on node 20.

Direct bumps: markdown-it 14.2, cross-env 10, date-fns 4.4, prettier 3.9,
@tailwindcss/typography 0.5.20, @11ty/eleventy-plugin-syntaxhighlight 5.0.2.
Replaced abandoned html-minifier (unpatched ReDoS, no fix released) with the
maintained html-minifier-terser fork; the .eleventy.js htmlmin transform is now
async. Transitive vulns patched via yarn resolutions. js-yaml resolves to 3.15.0
(3.x backport) which keeps gray-matter working.

Held: tailwindcss 3.4 (tailwind 4 is a config rewrite) and @11ty/eleventy 2
(eleventy 3 is an ESM migration) - both invasive.

Build output verified against a clean master build: every HTML page differs only
by the build-time ?v= cache-bust query; style.css differs only by an equivalent
refactor of @tailwindcss/typography's prose kbd-shadow variables (same rendered
result). Functionally identical.
2026-06-30 19:47:25 +01:00
Dmitry Verkhoturov f5ccfaa0e1 Update frontend dependencies to latest, bump pnpm to 10, clear all npm audit alerts
pnpm 8.15.9 -> 10.10.0 (packageManager + lockfile regenerated to v9). Frontend
CI (ci-frontend.yml, ci-frontend-api.yml, release.yml) and the production
Dockerfile bumped from node 16 + pnpm 8 to node 20 + pnpm 10 (pnpm 10 requires
node 18+). pnpm audit: no known vulnerabilities (was 63 alerts).

packages/api: bumped to latest including the major test stack - vitest 4, jsdom
29, @vitest/coverage-v8 4, @typescript-eslint 8.62, typescript 5.9, prettier
3.9, @types/node 26, and msw 1 -> 2. Migrated tests/test-utils.ts to the msw 2
http/HttpResponse API (capturing a compatible request shape) and made test base
URLs absolute so node 20's native fetch is intercepted; added the jsdom base
URL. type-check:api, lint:api and coverage:api (45 tests) all pass.

apps/remark42: safe in-major bumps (webpack 5.108, postcss, mini-css-extract,
html-webpack-plugin, ts-loader, webpack-dev-server 5.2.5, core-js, clsx 2,
lodash-es 4.18, dotenv 17, @types/*). Transitive vulns patched via
pnpm.overrides. type-check, lint, build, jest coverage (299 tests) and
translations all pass.

pnpm 10's stricter layout required a few pins to keep the app's preact-compat
setup compiling: preact 10.6.2 (override), react-intl 6.0.5 and
@testing-library/preact 3.2.2 (newer types break the build), tsconfig paths for
preact, @types/minimatch 5.1.2 (6.x is an empty stub) and cheerio 1.0.0-rc.12
(1.2 is ESM and breaks jest 28). Held: react/react-dom (preact compat alias),
babel 7, eslint 8, stylelint 14, jest 28, typescript 4.7 (app),
redux/react-redux - majors that change the bundle or need a config migration.

Build output verified against a clean master build: apps/remark42 output is
functionally identical (the only diffs are webpack module-id numbering and
css-module class tokens from the webpack/css-loader bump; all HTML, CSS values
and translations byte-identical).
2026-06-30 19:47:25 +01:00
UmputunandGitHub c8832e708c Merge pull request #2088 from umputun/deps/update-backend
Update backend dependencies to latest
2026-06-30 12:41:13 -05:00
Dmitry Verkhoturov 07c7926453 Update backend dependencies to latest
Update all Go modules in backend/ and backend/_example/memory_store/ to
their latest versions (chroma 2.27, go-redis 9.21, bbolt 1.5, slack 0.27,
golang.org/x/* and others); re-tidy and re-vendor, keep the example module
in sync.

Hold github.com/go-chi/chi/v5 at v5.2.5: v5.3.0 deprecates
middleware.RealIP (IP-spoofing advisories). Switching off RealIP changes
how the client IP is derived for rate limiting and votes, which is a
security decision better made on its own rather than inside a dependency
bump.

go test -race, go vet, golangci-lint and govulncheck all clean on both
modules.
2026-06-30 18:35:31 +01:00
UmputunandGitHub 34ed97b7a6 Merge pull request #2056 from umputun/dependabot/npm_and_yarn/frontend/postcss-8.5.10
chore(deps-dev): bump postcss from 8.4.14 to 8.5.10 in /frontend
2026-06-01 22:10:26 -05:00
UmputunandGitHub 0868b70fa9 Merge pull request #2063 from umputun/dependabot/npm_and_yarn/frontend/webpack-dev-server-5.2.4
chore(deps-dev): bump webpack-dev-server from 4.9.3 to 5.2.4 in /frontend
2026-06-01 22:10:21 -05:00
Umputun 589e956ade fix: handle REST shutdown before server start 2026-06-01 19:55:14 -05:00
Paul MineevandUmputun a21044738d fix typo in file name 2026-05-28 17:53:37 -05:00
Dmitry VerkhoturovandGitHub 929c06d957 site: fetch latest version client-side instead of embedding at build time (#2072)
* site: fetch latest version client-side instead of embedding at build time

The header version badge was filled in by site/src/data/github.js calling
the GitHub releases API at Eleventy build time and baking data[0].tag_name
into every page. This had three failure modes:

1. Layered cache: Buildx caches the yarn build layer; on a release-triggered
   workflow nothing under ./site changes, so the cached HTML (with the
   previous tag baked in) gets shipped. v1.16.0 went out and remark42.com
   kept showing v1.15.0 until a separate site/ commit landed and naturally
   invalidated the COPY layer.

2. Tag mismatch: the deploy pulls ghcr.io/umputun/remark42-site:master,
   but release events build :v1.16.0 and :latest only. A cache-skip
   workflow tweak wouldn't even reach the served image.

3. API propagation race: the workflow fires ~2s after release publish,
   so even with cache disabled the API might still return the previous
   tag from a stale read replica.

All three vanish if the version is fetched in the browser. GitHub serves
the /releases/latest response with Cache-Control: public, max-age=60 so
per-visitor cost is bounded; failures fall through silently and the
badge stays empty rather than wrong.

Changes:
- header.njk: replace {{ github.latestVersion }} with a
  <span data-remark42-version></span> placeholder.
- inline.js: add a fetch of /releases/latest that fills any
  [data-remark42-version] element on the page. fallback is no-op on any
  network/parse failure.
- delete site/src/data/github.js (Eleventy data file is no longer used).
- drop node-fetch from devDependencies (was used only by github.js).

* site: address PR review on version badge fetch

- gate DOM update on DOMContentLoaded — inline.js is loaded sync in <head>,
  so a cache-hit fetch can resolve before the placeholder span is parsed.
- hide placeholder span by default (`hidden`) so a failed/blocked fetch
  doesn't leave a 0.5rem stray gap before the github icon.
- log fetch failures (rate limit, offline, blocked) instead of silently
  swallowing — matches the prior behaviour of build-time github.js.

* site: cache latest version in sessionStorage with 1h TTL

avoids hitting the GitHub API on every page load — repeated navigations
within a tab read from sessionStorage instead. TTL caps stale display at
1h for very long-lived tabs. cleared on tab close, so each new session
fetches once and reuses the result throughout.

* site: address PR review on header & version fetch

Copilot review on the cache commit raised three points:

1. inline.js had a hard-coded `https://api.github.com/repos/umputun/remark42`
   while the templates use `site.githubUrl`. Rename inline.js → inline.njk
   so nunjucks evaluates it, add `githubApiUrl` to site.json, and template
   the fetch URL from it. One place to update if the repo ever moves.

2. header.njk aria-label said "Remark42's GitHub Repository" but the link
   target is `/releases`. Change to "{{ site.name }} releases on GitHub"
   so screen readers describe the actual destination.

3. console.warn on fetch failure (kept after umputun's prior review noted
   the trade-off): addressed in the PR description, no code change.

* site: actually template fetch URL via site.githubApiUrl

Copilot's second pass caught that 7697dcf3 added site.githubApiUrl,
renamed inline.js → inline.njk, and pointed head.njk at the .njk file
— but the fetch() call itself was never changed to use the template
variable. Build output looked correct because the literal hard-coded
URL happened to match what {{ site.githubApiUrl }} would expand to.

* site: normalise Nunjucks spacing in header.njk

{{ site.githubUrl}}/releases → {{ site.githubUrl }}/releases. Cosmetic
only; matches the spacing used everywhere else in the templates.
2026-05-28 13:10:35 -05:00
Dmitry VerkhoturovandGitHub 198efddb54 fix(frontend): no_footer scrollbar regression introduced in v1.16.0 (#2076)
* fix(frontend): no_footer scrollbar regression introduced in v1.16.0

Two unrelated changes in v1.16.0 combined to surface a scrollbar in
no_footer=true mode:

1. c26f45e5 removed the deprecated `scrolling="no"` iframe attribute
   on the grounds that "overflow is already hidden via CSS". That CSS
   (`overflow: hidden` in createIframe styles) is on the iframe ELEMENT
   in the parent page; it has no effect on the iframe DOCUMENT's own
   scrollbars. The spec-correct replacement is `overflow: hidden` on
   the iframe document's body — added here to global.css.

2. The negative `margin-bottom: -24px` on `.thread:last-child` was a
   trick to tighten the gap to the footer (combined with the footer's
   `margin-top: 48px` it collapsed to a 24px net gap). With no_footer
   the negative margin had no positive-margin sibling to collapse
   against and instead propagated up through .root, leaving body
   ~24px shorter than the visual content. The iframe height calc
   (`body.offsetHeight + 12`) then sized the iframe below the visible
   bottom of the last thread → scrollbar.

   Replace the negative-margin trick with a straight `margin-top: 24px`
   on `.copyright`. Same 24px visual gap when the footer is shown, no
   propagation when it isn't. The mix={styles.thread} on Thread becomes
   a dead reference and is dropped.

Closes #2073

* fix(frontend): drop dead Thread.mix prop after root.tsx removed its only caller

Both Copilot and umputun flagged this in PR review: after the parent
commit on this branch dropped `mix={styles.thread}` from root.tsx, the
`mix?: string` prop and the corresponding entry in the clsx() call in
thread.tsx are dead code — no caller passes it (the recursive Thread
render in thread.tsx:82 never did either). Remove the prop, the
destructure, and the clsx entry.
2026-05-28 13:02:25 -05:00
Dmitry VerkhoturovandGitHub 39408dffe8 fix: parameter docs + --help text inconsistencies (audit) (#2077)
* fix: address parameter docs and --help text inconsistencies

Audit findings from comparing site/src/docs/configuration/parameters/
against the backend flag tags.

Docs (parameters/index.md):
- image.bolt.file default was `/var/pictures.db` (absolute, looks like
  a system path); actual default is `./var/pictures.db` (relative,
  under the working dir).
- notify.webhook.template default was shown as
  `{"text": {{.Text | escapeJSONString}}}` — both the function name
  doesn't exist and the unescaped pipe inside the table cell broke the
  Description column count for that row. Real default is the literal
  `{"text": "{{.Text}}"}`.
- "Custom OAuth2 integration currently supports only one custom
  provider at a time" was a free-standing paragraph wedged between two
  table rows. kramdown terminated the table on that paragraph and
  restarted a new headerless table for the rest of the rows. Moved it
  to its own subsection after the table so the table stays contiguous.

Backend --help text (server.go):
- allowed-hosts description ended with a stray double apostrophe in
  `CSP 'frame-ancestors''` (typo).
- Deprecated auth.email.{port,passwd,user,tls} flag descriptions were
  shuffled — port said "SMTP password", passwd said "SMTP port", user
  said "enable TLS", tls said "SMTP TCP connection timeout". Fixed
  each to match the flag it's actually describing. Docs already had
  the correct descriptions for these deprecated flags.

* fix: webhook template flag default override masking safe fallback

Copilot flagged the audit's "real default" claim and was right. server.go:286
had default:"{\"text\": \"{{.Text}}\"}" — the literal, JSON-unsafe template
that produces invalid JSON if a comment contains a quote or newline. The
notify package (webhook.go:50) has a safer fallback:

    if params.Template == "" {
        params.Template = webhookDefaultTemplate
    }

where webhookDefaultTemplate is {"text": {{.Text | escapeJSONString}}}. But
go-flags applies its default tag at parse time, so the field is never empty
when the user omits --notify.webhook.template, and the safer fallback never
runs.

Drop the unsafe default tag so the webhook package's escapeJSONString-based
default takes effect. Also:
- fix the --help description (was "webhook authentication template", but
  it's a payload template, not an auth one; same for headers).
- update parameters/index.md to document the actual safe default
  ({{.Text | escapeJSONString}}); escape the cell's | as \| so kramdown
  doesn't treat it as a column separator.
- typo: "bellow" -> "below" in the headers env-delim comment.
2026-05-28 12:56:33 -05:00
Dmitry VerkhoturovandUmputun 6961dc24e5 docs: close backtick in smtp.login_auth default cell
opening backtick had no closer in the Default column, so kramdown saw a
broken cell and stopped rendering the parameters table — every row from
smtp.login_auth onward (~40 rows) rendered as raw pipe-delimited text
instead of HTML table cells. closes #2074
2026-05-26 14:12:07 -05:00
Umputun e8b9d70061 docs(site): bump remark42 image tag to v1.16.0 in kubernetes manual
the kubernetes deployment example pinned ghcr.io/umputun/remark42:v1.14.0,
two releases behind.
2026-05-22 16:15:37 -05:00
dependabot[bot]andGitHub e8c106f06b chore(deps-dev): bump webpack-dev-server in /frontend
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 4.9.3 to 5.2.4.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v4.9.3...v5.2.4)

---
updated-dependencies:
- dependency-name: webpack-dev-server
  dependency-version: 5.2.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 19:47:06 +00:00
dependabot[bot]andGitHub 54b7b3fdd4 chore(deps-dev): bump postcss from 8.4.14 to 8.5.10 in /frontend
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.14 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.14...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 06:54:50 +00:00
1047 changed files with 80306 additions and 46554 deletions
+3 -5
View File
@@ -10,10 +10,6 @@
/frontend/node_modules/ /frontend/node_modules/
/frontend/apps/remark42/node_modules/ /frontend/apps/remark42/node_modules/
/frontend/apps/remark42/public/ /frontend/apps/remark42/public/
# e2e tests arficats
/frontend/e2e/playwright-report/
/frontend/e2e/playwright/.cache/
/frontend/e2e/test-results/
# source files # source files
docker-compose.yml docker-compose.yml
@@ -36,4 +32,6 @@ debug.test
*.test *.test
remark42 remark42
/backend/var/ /backend/var/
/playwright-report/
# go e2e suite, never built into the image
/e2e/
+1 -1
View File
@@ -3,4 +3,4 @@
# review when someone opens a pull request. # review when someone opens a pull request.
* @umputun * @umputun
frontend/* @akellbl4 @Mavrin frontend/* @umputun @akellbl4 @Mavrin
+20 -26
View File
@@ -4,6 +4,11 @@
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2 version: 2
# npm updates are switched off entirely. open-pull-requests-limit bounds version
# updates only, so the ignore entries below are what also stops security updates;
# removing the npm entries would not work, as security updates come from alerts
# rather than from this file.
updates: updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
@@ -20,29 +25,18 @@ updates:
groups: groups:
"Go modules updates": "Go modules updates":
dependency-type: "production" dependency-type: "production"
- package-ecosystem: "gomod"
directory: "/e2e"
schedule:
interval: "monthly"
groups:
"Go modules updates":
dependency-type: "production"
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/frontend" directory: "/frontend"
open-pull-requests-limit: 0 open-pull-requests-limit: 0
schedule: ignore:
interval: "monthly" - dependency-name: "*"
groups:
"NPM modules updates":
dependency-type: "production"
"NPM modules updates for tests":
dependency-type: "development"
- package-ecosystem: "npm"
directory: "/frontend/packages/api"
open-pull-requests-limit: 0
schedule:
interval: "monthly"
groups:
"NPM modules updates":
dependency-type: "production"
"NPM modules updates for tests":
dependency-type: "development"
- package-ecosystem: "npm"
directory: "/frontend/e2e"
open-pull-requests-limit: 0
schedule: schedule:
interval: "monthly" interval: "monthly"
groups: groups:
@@ -53,6 +47,8 @@ updates:
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/frontend/apps/remark42" directory: "/frontend/apps/remark42"
open-pull-requests-limit: 0 open-pull-requests-limit: 0
ignore:
- dependency-name: "*"
schedule: schedule:
interval: "monthly" interval: "monthly"
groups: groups:
@@ -60,13 +56,11 @@ updates:
dependency-type: "production" dependency-type: "production"
"NPM modules updates for tests": "NPM modules updates for tests":
dependency-type: "development" dependency-type: "development"
- package-ecosystem: "npm" - package-ecosystem: "docker"
directory: "/site" directory: "/site"
open-pull-requests-limit: 0
schedule: schedule:
interval: "monthly" interval: "monthly"
groups: groups:
"NPM modules updates": "Site image updates":
dependency-type: "production" patterns:
"NPM modules updates for tests": - "*"
dependency-type: "development"
+44 -6
View File
@@ -7,13 +7,18 @@ on:
paths: paths:
- ".github/workflows/ci-backend.yml" - ".github/workflows/ci-backend.yml"
- "backend/**" - "backend/**"
- "Dockerfile"
- "docker-init.sh"
- ".dockerignore"
- "!backend/scripts/**" - "!backend/scripts/**"
- "!**.md" - "!**.md"
pull_request: pull_request:
types: [opened, reopened]
paths: paths:
- ".github/workflows/ci-backend.yml" - ".github/workflows/ci-backend.yml"
- "backend/**" - "backend/**"
- "Dockerfile"
- "docker-init.sh"
- ".dockerignore"
- "!backend/scripts/**" - "!backend/scripts/**"
- "!**.md" - "!**.md"
@@ -25,7 +30,7 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
@@ -35,14 +40,15 @@ jobs:
DEBUG: ${{secrets.DEBUG}} DEBUG: ${{secrets.DEBUG}}
- name: install go - name: install go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: "1.25" go-version: "1.25"
check-latest: true
cache-dependency-path: backend cache-dependency-path: backend
- name: test and build backend - name: test and build backend
run: | run: |
go test -race -timeout=60s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./... go test -race -timeout=300s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
go build -race ./... go build -race ./...
working-directory: backend/app working-directory: backend/app
@@ -60,13 +66,13 @@ jobs:
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: "v2.10.1" version: "v2.13.1"
working-directory: backend/app working-directory: backend/app
- name: golangci-lint on example directory - name: golangci-lint on example directory
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: "v2.10.1" version: "v2.13.1"
args: --config ../../.golangci.yml args: --config ../../.golangci.yml
working-directory: backend/_example/memory_store working-directory: backend/_example/memory_store
@@ -77,3 +83,35 @@ jobs:
working-directory: backend working-directory: backend
env: env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
vulncheck:
name: Vulnerability scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: install go
uses: actions/setup-go@v7
with:
go-version: "1.25"
check-latest: true
# both go.sum files so the cache key covers the main and example modules scanned below
cache-dependency-path: |
backend/go.sum
backend/_example/memory_store/go.sum
- name: govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.5.0
govulncheck ./...
(cd _example/memory_store && govulncheck ./...)
working-directory: backend
env:
# ignore the committed vendor dirs and resolve modules from the cache so
# both the main module and the nested example module scan consistently
GOFLAGS: "-mod=readonly"
+22 -25
View File
@@ -23,21 +23,13 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: set up Docker Buildx - name: set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: expose GitHub Actions cache
uses: actions/cache@v5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: free disk space - name: free disk space
run: | run: |
sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/share/dotnet
@@ -46,21 +38,26 @@ jobs:
docker system prune -af docker system prune -af
- name: build docker image without pushing - name: build docker image without pushing
run: | uses: docker/build-push-action@v7
docker buildx build --load \ with:
--cache-from type=local,src=/tmp/.buildx-cache \ context: .
--cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max \ platforms: linux/amd64
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \ load: true
--platform linux/amd64 . cache-from: type=gha,scope=main
cache-to: type=gha,scope=main,mode=max,ignore-error=true
build-args: |
SKIP_BACKEND_TEST=true
SKIP_FRONTEND_TEST=true
- name: build example docker image without pushing - name: build example docker image without pushing
run: | uses: docker/build-push-action@v7
docker buildx build --load \ with:
--cache-from type=local,src=/tmp/.buildx-cache \ context: .
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \ file: backend/_example/memory_store/Dockerfile
--platform linux/amd64 -f backend/_example/memory_store/Dockerfile . platforms: linux/amd64
load: true
- name: rotate cache cache-from: type=gha,scope=example
run: | cache-to: type=gha,scope=example,mode=max,ignore-error=true
rm -rf /tmp/.buildx-cache build-args: |
mv /tmp/.buildx-cache-new /tmp/.buildx-cache || true SKIP_BACKEND_TEST=true
SKIP_FRONTEND_TEST=true
+44
View File
@@ -0,0 +1,44 @@
name: compose
on:
push:
branches:
- master
paths:
- ".github/workflows/ci-compose.yml"
- "**compose*.yml"
- "**compose*.yaml"
pull_request:
paths:
- ".github/workflows/ci-compose.yml"
- "**compose*.yml"
- "**compose*.yaml"
jobs:
validate:
name: Validate compose files
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: validate tracked compose files
run: |
set -euo pipefail
n=0
# null-delimited to stay safe with unusual filenames; exclude this
# workflow (its name contains "compose") and vendored compose files.
# filenames are not echoed as workflow commands to avoid log-command injection
while IFS= read -r -d '' f; do
docker compose -f "$f" config --quiet
n=$((n + 1))
done < <(git ls-files -z '*compose*.yml' '*compose*.yaml' ':!:*/vendor/*' ':!:.github/*')
if [ "$n" -eq 0 ]; then
echo "no compose files found" >&2
exit 1
fi
echo "validated $n compose file(s)"
+37
View File
@@ -0,0 +1,37 @@
name: docs versions
on:
push:
branches:
- master
paths:
- ".github/workflows/ci-docs-versions.yml"
- "scripts/check-documented-versions.sh"
- "site/content/docs/getting-started/installation/index.md"
- "backend/go.mod"
- "frontend/apps/remark42/package.json"
- "frontend/.nvmrc"
pull_request:
paths:
- ".github/workflows/ci-docs-versions.yml"
- "scripts/check-documented-versions.sh"
- "site/content/docs/getting-started/installation/index.md"
- "backend/go.mod"
- "frontend/apps/remark42/package.json"
- "frontend/.nvmrc"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Documented versions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check documented versions against the repository
run: ./scripts/check-documented-versions.sh
-131
View File
@@ -1,131 +0,0 @@
name: "@remark42/api"
on:
push:
branches:
- master
paths:
- ".github/workflows/ci-frontend-api.yml"
- "frontend/packages/**"
- "!**.md"
pull_request:
paths:
- ".github/workflows/ci-frontend-api.yml"
- "frontend/packages/**"
- "!**.md"
jobs:
type-check:
name: Type check
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [ 16 ]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.4
with:
version: 8
run_install: false
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm i
working-directory: ./frontend
- name: Run type check
run: pnpm type-check:api
working-directory: ./frontend
lint:
name: Lint
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [ 16 ]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.4
with:
version: 8
run_install: false
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm i
working-directory: ./frontend
- name: Run linters
run: pnpm lint:api
working-directory: ./frontend/
test:
name: Tests & Coverage
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [ 16 ]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.4
with:
version: 8
run_install: false
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm i
working-directory: ./frontend
- name: Test & Coverage
run: pnpm coverage:api
working-directory: ./frontend
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
working-directory: ./frontend
codecov_yml_path: ./frontend/apps/remark42/codecov.yml
+38 -38
View File
@@ -6,12 +6,12 @@ on:
- master - master
paths: paths:
- ".github/workflows/ci-frontend.yml" - ".github/workflows/ci-frontend.yml"
- "frontend/apps/remark42/**" - "frontend/**"
- "!**.md" - "!**.md"
pull_request: pull_request:
paths: paths:
- ".github/workflows/ci-frontend.yml" - ".github/workflows/ci-frontend.yml"
- "frontend/apps/remark42/**" - "frontend/**"
- "!**.md" - "!**.md"
jobs: jobs:
@@ -22,30 +22,30 @@ jobs:
contents: read contents: read
strategy: strategy:
matrix: matrix:
node: [16] node: [24]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6.0.4 uses: pnpm/action-setup@v6.0.10
with: with:
version: 8 version: 10.10.0
run_install: false run_install: false
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: "pnpm" cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
run: pnpm i run: pnpm install --frozen-lockfile
working-directory: ./frontend working-directory: ./frontend/apps/remark42
- name: Translations check - name: Translations check
run: pnpm translation-check run: pnpm translation-check
@@ -58,30 +58,30 @@ jobs:
contents: read contents: read
strategy: strategy:
matrix: matrix:
node: [16] node: [24]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6.0.4 uses: pnpm/action-setup@v6.0.10
with: with:
version: 8 version: 10.10.0
run_install: false run_install: false
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: "pnpm" cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
run: pnpm i run: pnpm install --frozen-lockfile
working-directory: ./frontend working-directory: ./frontend/apps/remark42
- name: Run type check - name: Run type check
run: pnpm type-check run: pnpm type-check
@@ -94,30 +94,30 @@ jobs:
contents: read contents: read
strategy: strategy:
matrix: matrix:
node: [16] node: [24]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6.0.4 uses: pnpm/action-setup@v6.0.10
with: with:
version: 8 version: 10.10.0
run_install: false run_install: false
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: "pnpm" cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
run: pnpm i run: pnpm install --frozen-lockfile
working-directory: ./frontend working-directory: ./frontend/apps/remark42
- name: Run linters - name: Run linters
run: pnpm lint run: pnpm lint
@@ -134,14 +134,14 @@ jobs:
CI_JOB_NUMBER: 1 CI_JOB_NUMBER: 1
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6.0.4 uses: pnpm/action-setup@v6.0.10
with: with:
version: 8 version: 10.10.0
run_install: false run_install: false
- name: Check bundle size - name: Check bundle size
@@ -158,37 +158,37 @@ jobs:
contents: read contents: read
strategy: strategy:
matrix: matrix:
node: [16] node: [24]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6.0.4 uses: pnpm/action-setup@v6.0.10
with: with:
version: 8 version: 10.10.0
run_install: false run_install: false
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: "pnpm" cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
run: pnpm i run: pnpm install --frozen-lockfile
working-directory: ./frontend working-directory: ./frontend/apps/remark42
- name: Test & Coverage - name: Test & Coverage
run: pnpm coverage run: pnpm coverage
working-directory: ./frontend/apps/remark42 working-directory: ./frontend/apps/remark42
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v6 uses: codecov/codecov-action@v7
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
working-directory: ./frontend/apps/remark42 working-directory: ./frontend/apps/remark42
+33 -2
View File
@@ -9,16 +9,47 @@ on:
paths: paths:
- ".github/workflows/ci-site.yml" - ".github/workflows/ci-site.yml"
- "site/**" - "site/**"
- "!**/CLAUDE.md"
- "!site/README.md"
pull_request: pull_request:
paths: paths:
- ".github/workflows/ci-site.yml" - ".github/workflows/ci-site.yml"
- "site/**" - "site/**"
- "!**/CLAUDE.md"
- "!site/README.md"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
validate:
name: Build site image (pull request)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: set up docker buildx
uses: docker/setup-buildx-action@v4
- name: build image without pushing
uses: docker/build-push-action@v7
with:
context: ./site
load: true
push: false
cache-from: |
type=gha,scope=site-pr
type=gha,scope=site-linux/amd64
cache-to: type=gha,scope=site-pr,mode=max,ignore-error=true
build: build:
name: Build site image (${{ matrix.platform }}) name: Build site image (${{ matrix.platform }})
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
@@ -39,7 +70,7 @@ jobs:
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
@@ -60,7 +91,7 @@ jobs:
context: ./site context: ./site
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=site-${{ matrix.platform }} cache-from: type=gha,scope=site-${{ matrix.platform }}
cache-to: type=gha,scope=site-${{ matrix.platform }},mode=max cache-to: type=gha,scope=site-${{ matrix.platform }},mode=max,ignore-error=true
outputs: type=image,name=ghcr.io/umputun/remark42-site,push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=ghcr.io/umputun/remark42-site,push-by-digest=true,name-canonical=true,push=true
- name: export digest - name: export digest
+2 -2
View File
@@ -2,7 +2,7 @@ name: docker
on: on:
workflow_run: workflow_run:
workflows: [backend] workflows: [backend, frontend]
types: [completed] types: [completed]
concurrency: concurrency:
@@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
ref: ${{ github.event.workflow_run.head_sha }} ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false persist-credentials: false
+101 -18
View File
@@ -5,38 +5,121 @@ on:
branches: [master] branches: [master]
paths: paths:
- ".github/workflows/e2e-tests.yml" - ".github/workflows/e2e-tests.yml"
- "frontend/apps/remark42/**" - "backend/**"
- "frontend/e2e/**" - "frontend/**"
- "frontend/Dockerfile.e2e" - "e2e/**"
- "compose-e2e-test.yml"
- "Dockerfile"
- "!**.md"
pull_request: pull_request:
branches: [master] branches: [master]
paths: paths:
- ".github/workflows/e2e-tests.yml" - ".github/workflows/e2e-tests.yml"
- "frontend/apps/remark42/**" - "backend/**"
- "frontend/e2e/**" - "frontend/**"
- "frontend/Dockerfile.e2e" - "e2e/**"
- "compose-e2e-test.yml"
- "Dockerfile"
- "!**.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
tests: # cheap gate: catches a compile break or a lint regression in the build-tagged suite
name: Tests # without paying for the docker build and the browser download
timeout-minutes: 60 vet:
name: Vet
timeout-minutes: 10
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Build & run containers - name: Set up Go
id: tests uses: actions/setup-go@v7
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
- uses: actions/upload-artifact@v7
if: always()
with: with:
name: playwright-report go-version-file: e2e/go.mod
path: ./playwright-report/ cache-dependency-path: e2e/go.sum
- name: Vet
run: cd e2e && go vet -tags=e2e ./...
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.13.1
working-directory: e2e
args: --build-tags=e2e --config ../backend/.golangci.yml
tests:
name: Tests
needs: vet
# generous against the docker build plus one 8m go test: a job cancelled on timeout skips
# its own failure steps, so the run would end with neither logs nor traces
timeout-minutes: 45
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: e2e/go.mod
cache-dependency-path: e2e/go.sum
# two directories: the driver (node plus the npm package) and the browser builds,
# which include firefox and webkit for the rendering tests
- name: Cache playwright driver and browsers
uses: actions/cache@v6
with:
path: |
~/.cache/ms-playwright
~/.cache/ms-playwright-go
key: playwright-${{ hashFiles('e2e/go.sum') }}
restore-keys: playwright-
# E2E_STAMP is what the suite compares the running stack against, so a stack started here
# has to carry the same value `make e2e-up` and the suite itself would give it
- name: Build & start the stack
run: |
./e2e/tls/generate.sh
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 E2E_STAMP=$(./e2e/stamp.sh) \
docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
# no retry: a failure here is evidence about a suite too young to have a flake rate,
# and a rerun is how an intermittent regression becomes invisible. revisit when there
# are failures on record to look at
- name: Run e2e
# stamps this run's comment threads with the CI run, so a thread url in a trace or a
# log names the run it came from
env:
E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
# 20m, matching the Makefile. the suite runs about four minutes on a laptop and a runner
# is slower, so a tighter budget turns a loaded runner into a timeout panic instead of a
# readable failure. the job's own timeout above is what bounds a wedged run
run: cd e2e && go test -tags=e2e -count 1 -timeout 20m -v ./...
- name: Server logs on failure
if: failure()
run: docker compose -f compose-e2e-test.yml logs --tail=200
- name: Upload browser traces
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-traces
path: e2e/traces/
retention-days: 30 retention-days: 30
if-no-files-found: ignore
+22 -20
View File
@@ -12,10 +12,10 @@ on:
- "scripts/**" - "scripts/**"
- "backend/**" - "backend/**"
- "frontend/**" - "frontend/**"
- "!backend/**.md"
- "!frontend/**.md"
- "README.md" - "README.md"
- "LICENSE" - "LICENSE"
- "CLAUDE.md"
- "site/src/docs/getting-started/installation/index.md"
permissions: permissions:
contents: read contents: read
@@ -24,33 +24,34 @@ jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: install go - name: install go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: "1.25" go-version: "1.25"
check-latest: true
cache-dependency-path: backend/go.sum cache-dependency-path: backend/go.sum
- name: install pnpm - name: install pnpm
uses: pnpm/action-setup@v6.0.4 uses: pnpm/action-setup@v6.0.10
with: with:
version: 8 version: 10.10.0
run_install: false run_install: false
- name: install node - name: install node
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: 16 node-version: 24
cache: "pnpm" cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: test and build backend - name: test and build backend
run: | run: |
go test -race -timeout=120s ./... go test -race -timeout=300s ./...
go build -race ./... go build -race ./...
working-directory: backend/app working-directory: backend/app
env: env:
@@ -66,7 +67,7 @@ jobs:
- name: install frontend dependencies - name: install frontend dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
working-directory: frontend working-directory: frontend/apps/remark42
env: env:
CI: "true" CI: "true"
@@ -74,7 +75,7 @@ jobs:
run: | run: |
pnpm lint pnpm lint
pnpm type-check pnpm type-check
pnpm test -- --runInBand pnpm test --runInBand
working-directory: frontend/apps/remark42 working-directory: frontend/apps/remark42
env: env:
CI: "true" CI: "true"
@@ -99,33 +100,34 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: install go - name: install go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: "1.25" go-version: "1.25"
check-latest: true
cache-dependency-path: backend/go.sum cache-dependency-path: backend/go.sum
- name: install pnpm - name: install pnpm
uses: pnpm/action-setup@v6.0.4 uses: pnpm/action-setup@v6.0.10
with: with:
version: 8 version: 10.10.0
run_install: false run_install: false
- name: install node - name: install node
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: 16 node-version: 24
cache: "pnpm" cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: install frontend dependencies - name: install frontend dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
working-directory: frontend working-directory: frontend/apps/remark42
env: env:
CI: "true" CI: "true"
+6 -1
View File
@@ -25,8 +25,13 @@ compose-private-frontend.yml
compose-private.yml compose-private.yml
/backend/_example/*/vendor /backend/_example/*/vendor
http-client.env.json http-client.env.json
/playwright-report/
/backend/app/cmd/var /backend/app/cmd/var
# ralphex progress logs # ralphex progress logs
.ralphex/progress/ .ralphex/progress/
# traces from failed e2e runs
/e2e/traces/
# self-signed certificate for the e2e https services, made by e2e/tls/generate.sh
/e2e/tls/*.pem
-9
View File
@@ -26,26 +26,17 @@ builds:
- amd64 - amd64
- arm64 - arm64
- "386" - "386"
- arm
goarm:
- "7"
ignore: ignore:
- goos: darwin - goos: darwin
goarch: "386" goarch: "386"
- goos: darwin
goarch: arm
- goos: freebsd - goos: freebsd
goarch: arm64 goarch: arm64
- goos: freebsd - goos: freebsd
goarch: "386" goarch: "386"
- goos: freebsd
goarch: arm
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
- goos: windows - goos: windows
goarch: "386" goarch: "386"
- goos: windows
goarch: arm
ldflags: ldflags:
- -s -w -X main.revision={{ .Tag }}-{{ .ShortCommit }}-{{ trimsuffix (replace (replace .CommitDate "-" "") ":" "") "Z" }} - -s -w -X main.revision={{ .Tag }}-{{ .ShortCommit }}-{{ trimsuffix (replace (replace .CommitDate "-" "") ":" "") "Z" }}
+49 -8
View File
@@ -6,19 +6,40 @@
- Build: `make backend` - Build: `make backend`
- Race test: `make race_test` - Race test: `make race_test`
- **Backend Testing**: - **Backend Testing**:
- Run all tests: `cd backend/app && go test -timeout=60s -count 1 ./...` - Run all tests: `cd backend/app && go test -timeout=300s -count 1 ./...`
- Run single test: `cd backend/app && go test -run TestName ./path/to/package` - Run single test: `cd backend/app && go test -run TestName ./path/to/package`
- **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...` - **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`
- **Frontend**: - **Frontend**:
- Development: `cd frontend && pnpm dev:app` - Development: `cd frontend/apps/remark42 && pnpm dev`
- Tests: `cd frontend && pnpm test` - Tests: `cd frontend/apps/remark42 && pnpm test`
- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.
- **Lint**: - **Lint**:
- Backend: `cd backend && golangci-lint run` - Backend: `cd backend && golangci-lint run`
- **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml` - **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`
- Frontend: `cd frontend && pnpm lint` - Frontend: `cd frontend/apps/remark42 && pnpm lint`
- **Before committing**: Always run tests and linter on both main backend AND examples - **Before committing**: Always run tests and linter on both main backend AND examples
- **Dependency Updates**: - **Go module changes**:
- When updating Go modules in `backend/`, also run `go mod tidy` (and `go mod vendor`) in `backend/_example/memory_store` to keep indirect deps in sync. The example module replaces `github.com/umputun/remark42/backend` with `../../` so stale indirect deps there will break the example build. - **Any** change to `backend/go.mod` or `backend/go.sum` requires `go mod tidy` in `backend/_example/memory_store` in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the `go` directive, not only version updates.
- Only `go mod tidy` there, not `go mod vendor`: the example's vendor directory is gitignored (`.gitignore:26`), so its output is never committed, while a stale local copy silently becomes what the example resolves against.
- The example module replaces `github.com/umputun/remark42/backend` with `../../`, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the `test examples` CI step with `go: updates to go.mod needed; to update it: go mod tidy`.
- This applies to Dependabot pull requests too: the bot updates `backend/` only, so its Go module PRs need the example tidied before they can go green.
## Backend Test Determinism
Backend tests must never depend on how fast the machine is. CI runs them under `-race` with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.
- **Wait on a condition, never on a duration.** Use `require.Eventually` / `require.EventuallyWithT` to poll for the state the assertion needs, and `require.Never` when the point is that something did *not* happen. A bare `time.Sleep` before an assertion is a defect; sleeping until a deadline you computed, as `waitPastMillisecond` does, is not.
- **Polling closures must not touch `*testing.T`.** testify runs them on a separate goroutine, where `t.FailNow` is undefined behaviour. Assert on the `*assert.CollectT` that `EventuallyWithT` hands the closure, so the real error also lands in the failure message.
- **Mind the rate limiter when polling over HTTP.** Route groups are capped independently and most of the caps are hard-coded in `rest.go`, out of reach of a test: `/auth/` at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via `openRouteLimiter` (100 in `startupT`). Poll with the existing constants rather than a new number, `httpPoll` for anything issuing an HTTP request and `pollInterval` only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.
- **When a test needs time to have passed, pin the clock input rather than waiting for it:** `os.Chtimes` for file ages, an explicit `store.Comment.Timestamp` for anything that formats a timestamp.
- **Prefer a `testing/synctest` bubble** where the code under test has no real I/O. Inside one the clock is fake, so `time.Sleep` is instant and deterministic. `app/notify`, `app/store/service`, `app/store/image`, `app/store/engine`, `app/providers`, `app/migrator` and `_example/memory_store/accessor` already use it, and most surviving `time.Sleep` calls live in them.
- **Helpers fail loudly.** A wait that gives up must call `t.Fatal`/`require` naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run `goleak.VerifyTestMain`, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must `defer cancel()` or `defer srv.Shutdown()` right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.
- **Take ports and paths from outside the test.** Ports come from the kernel with `net.Listen("tcp", ":0")`, files from `t.TempDir()`. `go test ./...` runs package binaries concurrently, so a number out of a fixed range or a fixed name under `/tmp` lets two of them collide.
- **Close idle connections before shutting a test server down.** Clients built as `http.Client{Timeout: x}` share `http.DefaultTransport`, and `Shutdown` waits on their keep-alive connections until its own deadline expires.
- **Keep the test timeout budgets aligned.** `Makefile`, `ci-backend.yml`, `release.yml` and the command above all use `-timeout=300s`; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.
`chooseUnusedPort` and the server-start wait helpers are duplicated in `app`, `app/cmd`, `app/rest/api` and `_example/memory_store/server`. Nothing shares them today; keep the copies in step when changing one.
## Release Procedure ## Release Procedure
@@ -39,7 +60,22 @@ git push origin backend/vX.Y.Z
GoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow. GoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.
For local artifact runs, install GoReleaser, Go 1.25, Node 16+, PNPM 8, and Perl, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward. For local artifact runs, install GoReleaser, Go 1.25, Node 24+ and PNPM 10, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.
## Milestones and Issue Labels
**Milestones** — one `vX.Y.Z` milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.
- Decide which release a PR belongs to by whether its merge commit is **contained in a release tag** — not by comparing dates (a tag can be cut from an earlier commit, or moved). `git fetch --tags`, then `git tag --contains <merge_sha> | grep '^v' | sort -V | head -1` is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (`gh api repos/umputun/remark42/milestones -f title="vX.Y.Z"`).
- An **issue gets a milestone only when it was closed by a code change** (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as `duplicate`/`invalid`/`wontfix`/answered get no milestone.
- Find unassigned: `gh pr list --state merged --search "no:milestone"`, `gh issue list --state closed --search "no:milestone"`. Assign with `gh pr edit N --milestone "vX.Y.Z"` / `gh issue edit N --milestone "vX.Y.Z"`.
**Issue labels** — classify each issue with a type and an area (add priority when relevant):
- Type: `bug`, `enhancement`, `question`, `documentation`, `discussion`
- Area: `backend`, `frontend`, `site`, `CI`, `design`, `localization`
- Priority: `important`, `minor`, `some day`
- Contribution: `help wanted`, `good-first-issue`
- Resolution (on close, when applicable): `duplicate`, `invalid`, `wontfix`, `no-action-needed`
- PR auto-labels (applied by Dependabot/Actions, not manual PRs): `dependencies`, `go`, `javascript`, `github_actions`
## Code Style ## Code Style
- **Backend**: Formatting with golangci-lint, strict error handling - **Backend**: Formatting with golangci-lint, strict error handling
@@ -48,7 +84,7 @@ For local artifact runs, install GoReleaser, Go 1.25, Node 16+, PNPM 8, and Perl
- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx - **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx
## Key Backend Packages ## Key Backend Packages
- **Web/API**: `github.com/go-chi/chi/v5`, `github.com/go-pkgz/rest` - **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`
- **Auth**: `github.com/go-pkgz/auth/v2` - **Auth**: `github.com/go-pkgz/auth/v2`
- **Logging**: `github.com/go-pkgz/lgr` - **Logging**: `github.com/go-pkgz/lgr`
- **Testing**: `github.com/stretchr/testify` - **Testing**: `github.com/stretchr/testify`
@@ -57,3 +93,8 @@ For local artifact runs, install GoReleaser, Go 1.25, Node 16+, PNPM 8, and Perl
## Repository Structure ## Repository Structure
- Backend: Go server using BoltDB for storage - Backend: Go server using BoltDB for storage
- Frontend: Preact/Redux-based UI with iframe embedding - Frontend: Preact/Redux-based UI with iframe embedding
- `/web` is served from two sources, in lookup order: the frontend build output
(`frontend/apps/remark42/public`, embedded at `backend/app/cmd/web` or read from `--web-root`),
then `backend/app/webassets/assets`, embedded in the binary. A plain page or image the bundler
does not process belongs in `webassets`; anything needing templating or the widget's CSS/JS goes
through webpack. A name present in both is served from the frontend build.
+7 -6
View File
@@ -1,17 +1,19 @@
FROM --platform=$BUILDPLATFORM node:16.20-alpine AS frontend-deps FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-deps
ARG SKIP_FRONTEND_TEST ARG SKIP_FRONTEND_TEST
ARG SKIP_FRONTEND_BUILD ARG SKIP_FRONTEND_BUILD
# the manifest's prepare script installs husky hooks, which needs a git repository the build
# context does not have. husky itself skips on CI, and this is the same flag the build stage sets
ENV CI=true
WORKDIR /srv/frontend/ WORKDIR /srv/frontend/apps/remark42/
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/ COPY ./frontend/apps/remark42/package.json ./frontend/apps/remark42/pnpm-lock.yaml /srv/frontend/apps/remark42/
COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/
RUN \ RUN \
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \ if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
apk add --no-cache --update git && \ apk add --no-cache --update git && \
npm i -g pnpm@8; \ npm i -g pnpm@10.10.0; \
fi fi
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \ RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
@@ -60,7 +62,6 @@ RUN apk --no-cache add gcc libc-dev
ADD backend /build/backend ADD backend /build/backend
# to embed the frontend files statically into Remark42 binary # to embed the frontend files statically into Remark42 binary
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/ COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
RUN find /build/backend/app/cmd/web/ -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
WORKDIR /build/backend WORKDIR /build/backend
RUN echo go version: `go version` RUN echo go version: `go version`
+19 -5
View File
@@ -17,7 +17,7 @@ docker:
dockerx: dockerx:
docker buildx build --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) --build-arg CI=true \ docker buildx build --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) --build-arg CI=true \
--build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true \ --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true \
--progress=plain --platform linux/amd64,linux/arm/v7,linux/arm64 \ --progress=plain --platform linux/amd64,linux/arm64 \
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master . -t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
release: release:
@@ -26,7 +26,7 @@ release:
goreleaser release --snapshot --clean --skip=publish goreleaser release --snapshot --clean --skip=publish
race_test: race_test:
cd backend/app && go test -race -timeout=60s -count 1 ./... cd backend/app && go test -race -timeout=300s -count 1 ./...
backend: backend:
docker compose -f compose-dev-backend.yml build docker compose -f compose-dev-backend.yml build
@@ -39,7 +39,21 @@ rundev:
docker compose -f compose-private.yml build docker compose -f compose-private.yml build
docker compose -f compose-private.yml up docker compose -f compose-private.yml up
e2e: # stamped the same way the suite stamps a stack it starts itself, so one brought up here is
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests # accepted instead of rejected as belonging to another checkout
e2e-up:
./e2e/tls/generate.sh
E2E_STAMP=$$(./e2e/stamp.sh) docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e e2e-down:
docker compose -f compose-e2e-test.yml down -v
# the suite brings the stack up itself when it finds none, so e2e-up is only worth running
# to keep the containers between invocations
e2e:
cd e2e && go test -tags=e2e -count 1 -timeout 20m ./...
e2e-ui:
cd e2e && E2E_HEADLESS=false E2E_KEEP=1 go test -tags=e2e -count 1 -v -timeout 20m ./...
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e e2e-up e2e-down e2e-ui
-5
View File
@@ -20,9 +20,6 @@ linters:
- unparam - unparam
- unused - unused
settings: settings:
goconst:
min-len: 2
min-occurrences: 2
gosec: gosec:
excludes: excludes:
- G117 # false positive: struct field name matches "secret" pattern - G117 # false positive: struct field name matches "secret" pattern
@@ -38,8 +35,6 @@ linters:
govet: govet:
enable: enable:
- shadow - shadow
lll:
line-length: 140
misspell: misspell:
locale: US locale: US
exclusions: exclusions:
+1 -1
View File
@@ -1 +1 @@
../site/src/docs/contributing/backend/index.md ../site/content/docs/contributing/backend/index.md
+14 -20
View File
@@ -3,38 +3,32 @@ module github.com/umputun/remark42/memory_store
go 1.25.0 go 1.25.0
require ( require (
github.com/go-pkgz/jrpc v0.4.0 github.com/go-pkgz/jrpc v0.4.2
github.com/go-pkgz/lgr v0.12.3 github.com/go-pkgz/lgr v0.12.4
github.com/jessevdk/go-flags v1.6.1 github.com/jessevdk/go-flags v1.6.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.1
github.com/umputun/remark42/backend v1.1000.0 github.com/umputun/remark42/backend v1.1000.0
) )
require ( require (
github.com/Depado/bfchroma/v2 v2.0.0 // indirect github.com/Depado/bfchroma/v2 v2.0.0 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect github.com/go-pkgz/rest v1.24.0 // indirect
github.com/go-pkgz/rest v1.21.0 // indirect github.com/go-pkgz/routegroup v1.6.1 // indirect
github.com/go-pkgz/routegroup v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect go.etcd.io/bbolt v1.5.0 // indirect
golang.org/x/crypto v0.51.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/image v0.40.0 // indirect golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.54.0 // indirect golang.org/x/image v0.45.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/net v0.58.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect golang.org/x/sys v0.47.0 // indirect
) )
replace github.com/umputun/remark42/backend v1.1000.0 => ../../ replace github.com/umputun/remark42/backend v1.1000.0 => ../../
+30 -114
View File
@@ -4,133 +4,49 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-pkgz/jrpc v0.4.2 h1:gY5mmxp9/dFd1WsHybVZILQpF11YNWWS3Ga+Pc5aIAU=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/go-pkgz/jrpc v0.4.2/go.mod h1:ZtnMpIXYmwXh6W44XO2lE5Lh5J+6KeeMIvw+vF9xXRQ=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/go-pkgz/lgr v0.12.4 h1:lDeQ4BR28ldXrKau6BOjq7A8nHzcXz+MF4xUfV4l1Ok=
github.com/go-pkgz/jrpc v0.4.0 h1:oD7xiGrzDkndkuCjeHGugQXxbggLSV7O1QmHhoc5pYY= github.com/go-pkgz/lgr v0.12.4/go.mod h1:Lw6DkNRnCPyX07mqkiUK/p+eA1opq4GKkWfWia64RA8=
github.com/go-pkgz/jrpc v0.4.0/go.mod h1:JFoY3bRjRyx4M3CbEVDFQStMB1m2gmQ7OjqFK7q3kOo= github.com/go-pkgz/rest v1.24.0 h1:GAUCgx7U8xCOC2OynLjhCRMhtnMQH4d1mTdKpQyX2yI=
github.com/go-pkgz/lgr v0.12.3 h1:QDug7kRkEsuQtruT9fNF5PVT2kZUqCDPc4GmsgS3fP8= github.com/go-pkgz/rest v1.24.0/go.mod h1:dl3EWiuFB4hRTo2Sknj6UrQGFRAYvANK6/NyW8qQPxc=
github.com/go-pkgz/lgr v0.12.3/go.mod h1:lpCDgVvCIxBHZp8+sGCj9MPctIzKZyZ3QdE19ddqd54= github.com/go-pkgz/routegroup v1.6.1 h1:6I/0LabazpZsHAI+jYPeyH/KU2cvZF0bFylUScMNi+Q=
github.com/go-pkgz/rest v1.21.0 h1:Y/C4d/TpclJJDxqnH1RAcS6Hmox0RIReAlkwMcUWXK4= github.com/go-pkgz/routegroup v1.6.1/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-pkgz/rest v1.21.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE=
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -115,14 +115,17 @@ func TestRPC_imgCleanupHndl(t *testing.T) {
assert.Equal(t, 1462, len(img)) assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img) assert.Equal(t, gopherPNGBytes(), img)
// wait for image to expire // age the image past the ttl used below, so the reset that follows is what keeps it on
time.Sleep(time.Millisecond * 50) // staging rather than the image simply being young
// reset the time to cleanup const stagingTTL = 500 * time.Millisecond
time.Sleep(stagingTTL + 100*time.Millisecond)
// reset the time to cleanup, which leaves a full ttl before it could be collected again
err = ri.ResetCleanupTimer(id) err = ri.ResetCleanupTimer(id)
assert.NoError(t, err) assert.NoError(t, err)
// cleanup, should not affect the new image // cleanup, should not affect the new image
err = ri.Cleanup(context.TODO(), time.Millisecond*45) err = ri.Cleanup(context.TODO(), stagingTTL)
assert.NoError(t, err) assert.NoError(t, err)
// load after cleanup should succeed // load after cleanup should succeed
@@ -8,7 +8,6 @@ package server
import ( import (
"fmt" "fmt"
"math/rand"
"net" "net"
"net/http" "net/http"
"testing" "testing"
@@ -20,27 +19,31 @@ import (
"github.com/umputun/remark42/memory_store/accessor" "github.com/umputun/remark42/memory_store/accessor"
) )
func chooseRandomUnusedPort() (port int) { // chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
for range 10 { // collision between concurrently running package test binaries very unlikely
port = 40000 + int(rand.Int31n(10000)) func chooseUnusedPort(t *testing.T) int {
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil { t.Helper()
_ = ln.Close() ln, err := net.Listen("tcp", ":0")
break require.NoError(t, err, "no free port available")
} port := ln.Addr().(*net.TCPAddr).Port
} require.NoError(t, ln.Close())
return port return port
} }
func waitForHTTPServerStart(port int) { // waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
// wait for up to 3 seconds for server to start before returning it // port if it never does
func waitForHTTPServerStart(t *testing.T, port int) {
t.Helper()
client := http.Client{Timeout: time.Second} client := http.Client{Timeout: time.Second}
for range 300 { defer client.CloseIdleConnections()
time.Sleep(time.Millisecond * 10) require.Eventually(t, func() bool {
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil { resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
_ = resp.Body.Close() if err != nil {
return return false
} }
} _ = resp.Body.Close()
return true
}, 30*time.Second, 10*time.Millisecond, "http server on port %d didn't start", port)
} }
func prepTestStore(t *testing.T) (port int, teardown func()) { func prepTestStore(t *testing.T) (port int, teardown func()) {
@@ -61,14 +64,17 @@ func prepTestStore(t *testing.T) (port int, teardown func()) {
admRecDisabled.Enabled = false admRecDisabled.Enabled = false
adm.Set("test-site-disabled", admRecDisabled) adm.Set("test-site-disabled", admRecDisabled)
port = chooseRandomUnusedPort() port = chooseUnusedPort(t)
go func() { go func() {
_ = s.Run(port) _ = s.Run(port)
}() }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
return port, func() { return port, func() {
// every test client here uses http.DefaultTransport, so their keep-alive connections
// sit in one shared pool; Shutdown waits on them and hits its own 5s deadline otherwise
http.DefaultTransport.(*http.Transport).CloseIdleConnections()
require.NoError(t, s.Shutdown()) require.NoError(t, s.Shutdown())
} }
} }
+5 -6
View File
@@ -9,7 +9,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -58,7 +57,7 @@ func TestCleanup_IsSpam(t *testing.T) {
} }
func TestCleanup_postsInRange(t *testing.T) { func TestCleanup_postsInRange(t *testing.T) {
r := chi.NewRouter() r := http.NewServeMux()
cleanupRoutes(t, r, nil) cleanupRoutes(t, r, nil)
ts := httptest.NewServer(r) ts := httptest.NewServer(r)
defer ts.Close() defer ts.Close()
@@ -81,7 +80,7 @@ func TestCleanup_postsInRange(t *testing.T) {
} }
func TestCleanup_listComments(t *testing.T) { func TestCleanup_listComments(t *testing.T) {
r := chi.NewRouter() r := http.NewServeMux()
cleanupRoutes(t, r, nil) cleanupRoutes(t, r, nil)
ts := httptest.NewServer(r) ts := httptest.NewServer(r)
defer ts.Close() defer ts.Close()
@@ -107,7 +106,7 @@ func TestCleanup_listComments(t *testing.T) {
func TestCleanup_ExecuteSpam(t *testing.T) { func TestCleanup_ExecuteSpam(t *testing.T) {
cleaned := cleanedComments{} cleaned := cleanedComments{}
r := chi.NewRouter() r := http.NewServeMux()
cleanupRoutes(t, r, &cleaned) cleanupRoutes(t, r, &cleaned)
ts := httptest.NewServer(r) ts := httptest.NewServer(r)
defer ts.Close() defer ts.Close()
@@ -126,7 +125,7 @@ func TestCleanup_ExecuteSpam(t *testing.T) {
func TestCleanup_ExecuteTitle(t *testing.T) { func TestCleanup_ExecuteTitle(t *testing.T) {
titledComments := cleanedComments{} titledComments := cleanedComments{}
r := chi.NewRouter() r := http.NewServeMux()
cleanupRoutes(t, r, &titledComments) cleanupRoutes(t, r, &titledComments)
ts := httptest.NewServer(r) ts := httptest.NewServer(r)
defer ts.Close() defer ts.Close()
@@ -142,7 +141,7 @@ func TestCleanup_ExecuteTitle(t *testing.T) {
assert.Equal(t, []string{"/api/v1/admin/title/1", "/api/v1/admin/title/2", "/api/v1/admin/title/3", "/api/v1/admin/title/11"}, titledComments.ids) assert.Equal(t, []string{"/api/v1/admin/title/1", "/api/v1/admin/title/2", "/api/v1/admin/title/3", "/api/v1/admin/title/11"}, titledComments.ids)
} }
func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) { func cleanupRoutes(t *testing.T, r *http.ServeMux, c *cleanedComments) {
r.HandleFunc("/api/v1/list", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/api/v1/list", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "GET", r.Method) require.Equal(t, "GET", r.Method)
require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery) require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery)
+3 -5
View File
@@ -8,7 +8,6 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
@@ -133,15 +132,14 @@ func TestImport_ExecuteFailed(t *testing.T) {
} }
func TestImport_ExecuteTimeout(t *testing.T) { func TestImport_ExecuteTimeout(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import") assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method) assert.Equal(t, "POST", r.Method)
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body)) assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
time.Sleep(500 * time.Millisecond) // hold the response until the client gives up on its own timeout
fmt.Fprintln(w, "some response") <-r.Context().Done()
fmt.Fprintln(w, string(body))
})) }))
defer ts.Close() defer ts.Close()
+25 -8
View File
@@ -85,12 +85,13 @@ type ServerCommand struct {
Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"` Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"`
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"` WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"` UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
TrustedProxies []string `long:"trusted-proxy" env:"TRUSTED_PROXY" description:"reverse-proxy networks (CIDR or IP) trusted to set the client IP; if unset, trusted from any client (see docs)" env-delim:","`
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","` RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","` RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"` EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"` SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"`
ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"` ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"`
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments via CSP 'frame-ancestors''" env-delim:","` AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments via CSP 'frame-ancestors'" env-delim:","`
SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"` SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"`
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"` DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"`
DisableFancyTextFormatting bool `long:"disable-fancy-text-formatting" env:"DISABLE_FANCY_TEXT_FORMATTING" description:"disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc)"` DisableFancyTextFormatting bool `long:"disable-fancy-text-formatting" env:"DISABLE_FANCY_TEXT_FORMATTING" description:"disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc)"`
@@ -101,7 +102,7 @@ type ServerCommand struct {
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"` Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
} `group:"ttl" namespace:"ttl" env-namespace:"TTL"` } `group:"ttl" namespace:"ttl" env-namespace:"TTL"`
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of server-set cookie; with this enabled, frontend stores the JWT in a client-side cookie (note: increases vulnerability to XSS attacks)"` SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"also send JWT as a header, so the frontend can store it in a client-side cookie that survives third-party cookie blocking; server-set cookies are still sent (note: increases vulnerability to XSS attacks)"`
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint
Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"` Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
@@ -123,10 +124,10 @@ type ServerCommand struct {
Subject string `long:"subj" env:"SUBJ" default:"remark42 confirmation" description:"email's subject"` Subject string `long:"subj" env:"SUBJ" default:"remark42 confirmation" description:"email's subject"`
ContentType string `long:"content-type" env:"CONTENT_TYPE" default:"text/html" description:"content type"` ContentType string `long:"content-type" env:"CONTENT_TYPE" default:"text/html" description:"content type"`
Host string `long:"host" env:"HOST" description:"[deprecated, use --smtp.host] SMTP host"` Host string `long:"host" env:"HOST" description:"[deprecated, use --smtp.host] SMTP host"`
Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP password"` Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP port"`
SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP port"` SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP password"`
SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] enable TLS"` SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] SMTP user name"`
TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] SMTP TCP connection timeout"` TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] enable TLS"`
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"[deprecated, use --smtp.timeout] SMTP TCP connection timeout"` TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"[deprecated, use --smtp.timeout] SMTP TCP connection timeout"`
MsgTemplate string `long:"template" env:"TEMPLATE" description:"[deprecated] message template file" default:"email_confirmation_login.html.tmpl"` MsgTemplate string `long:"template" env:"TEMPLATE" description:"[deprecated] message template file" default:"email_confirmation_login.html.tmpl"`
} `group:"email" namespace:"email" env-namespace:"EMAIL"` } `group:"email" namespace:"email" env-namespace:"EMAIL"`
@@ -251,6 +252,7 @@ type TelegramGroup struct {
type SMTPGroup struct { type SMTPGroup struct {
Host string `long:"host" env:"HOST" description:"SMTP host"` Host string `long:"host" env:"HOST" description:"SMTP host"`
Port int `long:"port" env:"PORT" description:"SMTP port"` Port int `long:"port" env:"PORT" description:"SMTP port"`
HELOHost string `long:"helo_host" env:"HELO_HOST" description:"SMTP HELO/EHLO hostname"`
Username string `long:"username" env:"USERNAME" description:"SMTP user name"` Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
Password string `long:"password" env:"PASSWORD" description:"SMTP password"` Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
TLS bool `long:"tls" env:"TLS" description:"enable TLS"` TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
@@ -283,8 +285,8 @@ type NotifyGroup struct {
} `group:"slack" namespace:"slack" env-namespace:"SLACK"` } `group:"slack" namespace:"slack" env-namespace:"SLACK"`
Webhook struct { Webhook struct {
URL string `long:"url" env:"URL" description:"webhook URL for admin notifications"` URL string `long:"url" env:"URL" description:"webhook URL for admin notifications"`
Template string `long:"template" env:"TEMPLATE" description:"webhook authentication template" default:"{\"text\": \"{{.Text}}\"}"` Template string `long:"template" env:"TEMPLATE" description:"webhook payload template (Go text/template); falls back to {\"text\": {{.Text | escapeJSONString}}} when empty"`
Headers []string `long:"headers" description:"webhook authentication headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code bellow to allow , inside "" Headers []string `long:"headers" description:"webhook headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code below to allow , inside ""
Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"webhook timeout" default:"5s"` Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"webhook timeout" default:"5s"`
} `group:"webhook" namespace:"webhook" env-namespace:"WEBHOOK"` } `group:"webhook" namespace:"webhook" env-namespace:"WEBHOOK"`
} }
@@ -596,6 +598,18 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
} }
log.Printf("[INFO] root url=%s", s.RemarkURL) log.Printf("[INFO] root url=%s", s.RemarkURL)
// parse trusted proxies up front so a bad CIDR fails before any resource is allocated
trustedProxies, err := api.ParseTrustedProxies(s.TrustedProxies)
if err != nil {
return nil, fmt.Errorf("invalid --trusted-proxy: %w", err)
}
switch {
case len(trustedProxies) == 0:
log.Printf("[WARN] --trusted-proxy not set: forwarding headers are trusted from any client and can be spoofed to bypass rate limiting / vote dedup; set it behind a reverse proxy (see docs)")
case api.TrustsAnyPeer(trustedProxies):
log.Printf("[WARN] --trusted-proxy has a catch-all (0.0.0.0/0 or ::/0): forwarding headers are trusted from any client, re-opening the spoofing bypass; scope it to your proxy network")
}
storeEngine, err := s.makeDataStore() storeEngine, err := s.makeDataStore()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to make data store engine: %w", err) return nil, fmt.Errorf("failed to make data store engine: %w", err)
@@ -703,6 +717,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
Migrator: migr, Migrator: migr,
ReadOnlyAge: s.ReadOnlyAge, ReadOnlyAge: s.ReadOnlyAge,
SharedSecret: s.SharedSecret, SharedSecret: s.SharedSecret,
TrustedProxies: trustedProxies,
Authenticator: authenticator, Authenticator: authenticator,
Cache: loadingCache, Cache: loadingCache,
NotifyService: notifyService, NotifyService: notifyService,
@@ -1152,6 +1167,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
params := sender.EmailParams{ params := sender.EmailParams{
Host: s.SMTP.Host, Host: s.SMTP.Host,
Port: s.SMTP.Port, Port: s.SMTP.Port,
HELOHost: s.SMTP.HELOHost,
SMTPUserName: s.SMTP.Username, SMTPUserName: s.SMTP.Username,
SMTPPassword: s.SMTP.Password, SMTPPassword: s.SMTP.Password,
TimeOut: s.SMTP.TimeOut, TimeOut: s.SMTP.TimeOut,
@@ -1308,6 +1324,7 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
smtpParams := ntf.SMTPParams{ smtpParams := ntf.SMTPParams{
Host: s.SMTP.Host, Host: s.SMTP.Host,
Port: s.SMTP.Port, Port: s.SMTP.Port,
HELOHost: s.SMTP.HELOHost,
TLS: s.SMTP.TLS, TLS: s.SMTP.TLS,
StartTLS: s.SMTP.StartTLS, StartTLS: s.SMTP.StartTLS,
InsecureSkipVerify: s.SMTP.InsecureSkipVerify, InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
+273 -87
View File
@@ -5,7 +5,6 @@ import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io" "io"
"math/rand"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -25,15 +24,33 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
const (
// budget for a server to bind and answer, generous enough for a loaded CI runner
serverStartTimeout = 30 * time.Second
serverStartPoll = 10 * time.Millisecond
// budget for a server to stop once asked. tight enough to catch a shutdown that hangs,
// loose enough not to depend on how loaded the runner is
serverStopTimeout = 10 * time.Second
// connect budget for a single probe. kept off the poll interval so a slow loopback connect
// on a loaded runner does not look like a server that is not listening
probeDialTimeout = time.Second
// the /auth/ group is limited to 2 req/s, so retries sit at its refill interval rather than
// above it, which would only manufacture more 429s
authRetryPoll = 500 * time.Millisecond
)
func TestServerApp(t *testing.T) { func TestServerApp(t *testing.T) {
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port o.Port = port
return o return o
}) })
go func() { _ = app.run(ctx) }() go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
// send ping // send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port)) resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
@@ -68,7 +85,7 @@ func TestServerApp(t *testing.T) {
} }
func TestServerApp_DevMode(t *testing.T) { func TestServerApp_DevMode(t *testing.T) {
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port o.Port = port
o.AdminPasswd = "password" o.AdminPasswd = "password"
@@ -77,7 +94,7 @@ func TestServerApp_DevMode(t *testing.T) {
}) })
go func() { _ = app.run(ctx) }() go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
providers := app.restSrv.Authenticator.Providers() providers := app.restSrv.Authenticator.Providers()
require.Equal(t, 11+1, len(providers), "extra auth provider") require.Equal(t, 11+1, len(providers), "extra auth provider")
@@ -97,7 +114,7 @@ func TestServerApp_DevMode(t *testing.T) {
} }
func TestServerApp_CustomOAuthProvider(t *testing.T) { func TestServerApp_CustomOAuthProvider(t *testing.T) {
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port o.Port = port
o.Auth.Custom.Name = "oidc" o.Auth.Custom.Name = "oidc"
@@ -110,7 +127,7 @@ func TestServerApp_CustomOAuthProvider(t *testing.T) {
}) })
go func() { _ = app.run(ctx) }() go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
providers := app.restSrv.Authenticator.Providers() providers := app.restSrv.Authenticator.Providers()
require.Equal(t, 11+1, len(providers), "extra auth provider") require.Equal(t, 11+1, len(providers), "extra auth provider")
@@ -121,7 +138,7 @@ func TestServerApp_CustomOAuthProvider(t *testing.T) {
} }
func TestServerApp_AnonMode(t *testing.T) { func TestServerApp_AnonMode(t *testing.T) {
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port o.Port = port
o.Auth.Anonymous = true o.Auth.Anonymous = true
@@ -129,7 +146,7 @@ func TestServerApp_AnonMode(t *testing.T) {
}) })
go func() { _ = app.run(ctx) }() go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
providers := app.restSrv.Authenticator.Providers() providers := app.restSrv.Authenticator.Providers()
require.Equal(t, 11+1, len(providers), "extra auth provider for anon") require.Equal(t, 11+1, len(providers), "extra auth provider for anon")
@@ -148,8 +165,7 @@ func TestServerApp_AnonMode(t *testing.T) {
assert.Equal(t, "pong", string(body)) assert.Equal(t, "pong", string(body))
// try to login with good name // try to login with good name
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port)) resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -168,57 +184,43 @@ func TestServerApp_AnonMode(t *testing.T) {
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
// try to login with non-latin name // try to login with non-latin name
time.Sleep(time.Second) nonLatin := fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port)
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port)) resp = getRetryThrottled(t, &client, nonLatin)
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
// try to login with bad name // try to login with bad name
time.Sleep(time.Second) resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", port))
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with short name // try to login with short name
time.Sleep(time.Second) resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%%20%%20&aud=remark", port))
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%%20%%20&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with name what have space in prefix // try to login with name what have space in prefix
time.Sleep(time.Second) resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%%20somebody&aud=remark", port))
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%%20somebody&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with name what have space in suffix // try to login with name what have space in suffix
time.Sleep(time.Second) resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=somebody%%20&aud=remark", port))
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=somebody%%20&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with long name // try to login with long name
time.Sleep(time.Second)
ln := strings.Repeat("x", 65) ln := strings.Repeat("x", 65)
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln)) resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln))
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with admin name // try to login with admin name
time.Sleep(time.Second) resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umpUtun&aud=remark", port))
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umpUtun&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
// try to add a comment as anonymous with admin name // try to add a comment as anonymous with admin name
time.Sleep(time.Second)
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port), req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`)) strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
require.NoError(t, err) require.NoError(t, err)
@@ -250,12 +252,12 @@ func getAuthFromCookie(t *testing.T, app *serverApp, resp *http.Response) (tkn s
func TestServerApp_WithSSL(t *testing.T) { func TestServerApp_WithSSL(t *testing.T) {
opts := ServerCommand{} opts := ServerCommand{}
sslPort := chooseRandomUnusedPort() sslPort := chooseUnusedPort(t)
opts.SetCommon(CommonOpts{RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), SharedSecret: "123456"}) opts.SetCommon(CommonOpts{RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), SharedSecret: "123456"})
// prepare options // prepare options
p := flags.NewParser(&opts, flags.Default) p := flags.NewParser(&opts, flags.Default)
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=" + strconv.Itoa(port), "--store.bolt.path=/tmp/xyz", "--backup=/tmp", _, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=" + strconv.Itoa(port), "--store.bolt.path=/tmp/xyz", "--backup=/tmp",
"--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db", "--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db",
"--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem", "--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem",
@@ -270,8 +272,9 @@ func TestServerApp_WithSSL(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() // this context is not the one createAppFromCmd registers for cleanup
go func() { _ = app.run(ctx) }() go func() { _ = app.run(ctx) }()
waitForHTTPSServerStart(sslPort) waitForServerStart(t, sslPort, port) // the redirect check below uses the plain http port
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
@@ -312,7 +315,7 @@ func TestServerApp_WithRemote(t *testing.T) {
// prepare options // prepare options
p := flags.NewParser(&opts, flags.Default) p := flags.NewParser(&opts, flags.Default)
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none", _, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
"--store.type=rpc", "--store.rpc.api=http://127.0.0.1", "--store.type=rpc", "--store.rpc.api=http://127.0.0.1",
"--port=" + strconv.Itoa(port), "--avatar.fs.path=/tmp", "--port=" + strconv.Itoa(port), "--avatar.fs.path=/tmp",
@@ -326,8 +329,9 @@ func TestServerApp_WithRemote(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() // this context is not the one createAppFromCmd registers for cleanup
go func() { _ = app.run(ctx) }() go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
// send ping // send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port)) resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
@@ -377,6 +381,16 @@ func TestServerApp_Failed(t *testing.T) {
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com") assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
t.Log(err) t.Log(err)
// invalid trusted proxy CIDR fails fast, before any resource is created
opts = ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
p = flags.NewParser(&opts, flags.Default)
_, err = p.ParseArgs([]string{"--backup=/tmp", "--trusted-proxy=nonsense"})
assert.NoError(t, err)
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err, `invalid --trusted-proxy: invalid trusted proxy "nonsense"`)
t.Log(err)
// wrong store type // wrong store type
opts = ServerCommand{} opts = ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"}) opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
@@ -506,34 +520,117 @@ func TestServerApp_InvalidCustomOAuthProviderName(t *testing.T) {
} }
func TestServerApp_Shutdown(t *testing.T) { func TestServerApp_Shutdown(t *testing.T) {
port := chooseUnusedPort(t)
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = chooseRandomUnusedPort() o.Port = port
return o return o
}) })
time.AfterFunc(100*time.Millisecond, func() {
cancel() // cancel once the server actually answers, so the test measures shutdown and not startup.
}) // the deferred cancel also covers a failed wait, keeping app.run from racing the next test
st := time.Now() errCh := make(chan error, 1)
err := app.run(ctx) go func() { errCh <- app.run(ctx) }()
assert.NoError(t, err) defer cancel()
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100msec") waitForHTTPServerStart(t, port)
cancel()
select {
case err := <-errCh:
assert.NoError(t, err)
case <-time.After(serverStopTimeout):
t.Fatal("server app did not stop after context cancel")
}
app.Wait() app.Wait()
} }
func TestServerApp_MainSignal(t *testing.T) { // TestServerApp_ClaimsUpd covers the hook the authenticator runs on every token mint, refresh
done := make(chan struct{}) // included: it stamps admin, blocked and email onto the claims and blocks impersonation of a
go func() { // restricted name. Calling the updater directly keeps it independent of when a token expires.
<-done func TestServerApp_ClaimsUpd(t *testing.T) {
time.Sleep(250 * time.Millisecond) port := chooseUnusedPort(t)
err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM) app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port
return o
})
// the app owns stores and services that only run closes, so it goes through the usual
// lifecycle here rather than being built and abandoned
go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(t, port)
defer app.Wait()
defer cancel()
upd := app.restSrv.Authenticator.TokenService().ClaimsUpd
require.NotNil(t, upd, "claims updater wired into the token service")
claimsFor := func(id, name string) token.Claims {
return token.Claims{
RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"remark"}},
User: &token.User{ID: id, Name: name},
}
}
t.Run("plain user gets no attributes", func(t *testing.T) {
res := upd.Update(claimsFor("provider1_dev", "developer"))
assert.False(t, res.User.IsAdmin(), "not an admin")
assert.False(t, res.User.BoolAttr("blocked"), "not blocked")
assert.Empty(t, res.User.Email, "no email on file")
})
t.Run("admin from the admin store", func(t *testing.T) {
res := upd.Update(claimsFor("id1", "admin one"))
assert.True(t, res.User.IsAdmin(), "id1 is listed as admin")
})
t.Run("blocked user carries the blocked attribute", func(t *testing.T) {
require.NoError(t, app.restSrv.DataService.SetBlock("remark", "blocked_user", true, time.Hour))
res := upd.Update(claimsFor("blocked_user", "blocked"))
assert.True(t, res.User.BoolAttr("blocked"), "block is reflected on refresh")
})
t.Run("email is read from the store", func(t *testing.T) {
_, err := app.restSrv.DataService.SetUserEmail("remark", "with_email", "user@example.com")
require.NoError(t, err) require.NoError(t, err)
}() res := upd.Update(claimsFor("with_email", "someone"))
assert.Equal(t, "user@example.com", res.User.Email)
})
t.Run("anonymous impersonating a restricted name is blocked", func(t *testing.T) {
res := upd.Update(claimsFor("anonymous_x", " UmpUtun "))
assert.True(t, res.User.BoolAttr("blocked"), "restricted name matched case and space insensitively")
})
t.Run("email user impersonating a restricted name is blocked", func(t *testing.T) {
res := upd.Update(claimsFor("email_x", "bobuk"))
assert.True(t, res.User.BoolAttr("blocked"))
})
t.Run("regular user may carry a restricted name", func(t *testing.T) {
res := upd.Update(claimsFor("provider1_someone", "umputun"))
assert.False(t, res.User.BoolAttr("blocked"), "only anonymous and email logins are checked")
})
t.Run("claims without a user pass through", func(t *testing.T) {
res := upd.Update(token.Claims{RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"remark"}}})
assert.Nil(t, res.User)
})
t.Run("claims without exactly one audience pass through", func(t *testing.T) {
c := claimsFor("id1", "admin one")
c.Audience = jwt.ClaimStrings{"remark", "second"}
res := upd.Update(c)
assert.False(t, res.User.IsAdmin(), "attributes need a single audience to resolve the site")
})
}
func TestServerApp_MainSignal(t *testing.T) {
sigErr := make(chan error, 1)
s := ServerCommand{} s := ServerCommand{}
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"}) s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
p := flags.NewParser(&s, flags.Default) p := flags.NewParser(&s, flags.Default)
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
args := []string{"test", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt", args := []string{"test", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt",
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--image.fs.path=/tmp"} "--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--image.fs.path=/tmp"}
defer os.Remove("/tmp/xyz") defer os.Remove("/tmp/xyz")
@@ -541,11 +638,52 @@ func TestServerApp_MainSignal(t *testing.T) {
defer os.Remove("/tmp/ava-test.db") defer os.Remove("/tmp/ava-test.db")
_, err := p.ParseArgs(args) _, err := p.ParseArgs(args)
require.NoError(t, err) require.NoError(t, err)
st := time.Now() // the signal goes out only once the server answers: SIGTERM landing before the handler is
close(done) // installed kills the test process, so a wait that timed out reports instead of sending it
go func() {
started := waitForServerPort(port, serverStartTimeout)
// signal either way: Execute blocks until it gets one, so bailing out here would hang
// the test until the package timeout instead of failing with the reason
killErr := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
if !started {
killErr = fmt.Errorf("server on port %d didn't start", port)
}
sigErr <- killErr
}()
err = s.Execute(args) err = s.Execute(args)
assert.NoError(t, err, "execute should be without errors") assert.NoError(t, err, "execute should be without errors")
assert.True(t, time.Since(st).Seconds() < 5, "should take under five sec", time.Since(st).Seconds()) require.NoError(t, <-sigErr, "SIGTERM not delivered")
}
func TestServerApp_RunCanceledBeforeRESTStart(t *testing.T) {
port := chooseUnusedPort(t)
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port
return o
})
cancel()
errCh := make(chan error, 1)
go func() { errCh <- app.run(ctx) }()
// the budget is generous on purpose: the assertion is that run exits rather than hangs, and
// store construction can take a while on a loaded runner
select {
case err := <-errCh:
require.NoError(t, err)
app.Wait()
case <-time.After(serverStartTimeout):
waitForHTTPServerStart(t, port)
app.restSrv.Shutdown()
select {
case <-errCh:
app.Wait()
case <-time.After(serverStartTimeout):
t.Fatal("server app did not stop after forced REST shutdown")
}
t.Fatal("server app should exit when context is canceled before REST server starts")
}
} }
func TestServerApp_DeprecatedArgs(t *testing.T) { func TestServerApp_DeprecatedArgs(t *testing.T) {
@@ -709,24 +847,25 @@ func Test_ACMEEmail(t *testing.T) {
} }
func TestServerAuthHooks(t *testing.T) { func TestServerAuthHooks(t *testing.T) {
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port o.Port = port
return o return o
}) })
go func() { _ = app.run(ctx) }() go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
// make a token for user dev // make a token for user dev. nothing here checks expiry, so the lifetime only has to
// outlast the whole test
tkService := app.restSrv.Authenticator.TokenService() tkService := app.restSrv.Authenticator.TokenService()
tkService.TokenDuration = time.Second tkService.TokenDuration = time.Hour
claims := token.Claims{ claims := token.Claims{
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"remark"}, Audience: jwt.ClaimStrings{"remark"},
Issuer: "remark", Issuer: "remark",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)), NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
}, },
User: &token.User{ User: &token.User{
@@ -829,8 +968,7 @@ func TestServerAuthHooks(t *testing.T) {
body, err = io.ReadAll(resp.Body) body, err = io.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized, assert.Equal(t, http.StatusForbidden, resp.StatusCode, "blocked user can't post, \n"+tk+"\n"+string(body))
"blocked user can't post, \n"+tk+"\n"+string(body))
cancel() cancel()
app.Wait() app.Wait()
@@ -930,40 +1068,79 @@ func Test_getAllowedRedirectHosts(t *testing.T) {
} }
} }
func chooseRandomUnusedPort() (port int) { // chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
for range 10 { // collision between concurrently running package test binaries very unlikely
port = 40000 + int(rand.Int31n(10000)) func chooseUnusedPort(t *testing.T) int {
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil { t.Helper()
_ = ln.Close() ln, err := net.Listen("tcp", ":0")
break require.NoError(t, err, "no free port available")
} port := ln.Addr().(*net.TCPAddr).Port
} require.NoError(t, ln.Close())
return port return port
} }
func waitForHTTPServerStart(port int) { // waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
// wait for up to 3 seconds for server to start before returning it // port if it never does
func waitForHTTPServerStart(t *testing.T, port int) {
t.Helper()
client := http.Client{Timeout: time.Second} client := http.Client{Timeout: time.Second}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
for range 300 { require.Eventually(t, func() bool {
time.Sleep(time.Millisecond * 10) resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil { if err != nil {
_ = resp.Body.Close() return false
return
} }
_ = resp.Body.Close()
return true
}, serverStartTimeout, serverStartPoll, "http server on port %d didn't start", port)
}
// waitForServerStart blocks until something accepts on every listed port, failing the test
// naming the port that never came up
func waitForServerStart(t *testing.T, ports ...int) {
t.Helper()
for _, port := range ports {
require.True(t, waitForServerPort(port, serverStartTimeout), "server on port %d didn't start", port)
} }
} }
func waitForHTTPSServerStart(port int) { // getRetryThrottled issues a GET and retries while the auth routes answer 429, since the /auth/
// wait for up to 3 seconds for HTTPS server to start // group is limited to 2 req/s and this test logs in more often than that. a transport error is
for range 300 { // retried a couple of times and then reported as itself, so a dead server is not read as throttling
time.Sleep(time.Millisecond * 10) func getRetryThrottled(t *testing.T, client *http.Client, url string) *http.Response {
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10) t.Helper()
if conn != nil { const transportRetries = 2
_ = conn.Close() errCount := 0
break for deadline := time.Now().Add(serverStartTimeout); time.Now().Before(deadline); time.Sleep(authRetryPoll) {
r, err := client.Get(url)
if err != nil {
errCount++
require.LessOrEqual(t, errCount, transportRetries, "request to %s failed: %v", url, err)
continue
} }
if r.StatusCode == http.StatusTooManyRequests {
_ = r.Body.Close()
continue
}
return r
} }
t.Fatalf("request to %s kept being rate limited", url)
return nil
}
// waitForServerPort blocks until something accepts on port, reporting whether it came up.
// unlike the require-based helpers it is safe to call off the test goroutine.
func waitForServerPort(port int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), probeDialTimeout)
if err == nil {
_ = conn.Close()
return true
}
time.Sleep(serverStartPoll)
}
return false
} }
func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context, context.CancelFunc) { func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
@@ -1026,6 +1203,9 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) { func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
// a require in a readiness wait exits the test goroutine, so without this an app started in
// a goroutine would never be stopped and goleak would report it instead of the failure
t.Cleanup(cancel)
app, err := cmd.newServerApp(ctx) app, err := cmd.newServerApp(ctx)
require.NoError(t, err) require.NoError(t, err)
return app, ctx, cancel return app, ctx, cancel
@@ -1035,8 +1215,14 @@ func TestMain(m *testing.M) {
// ignore is added only for GitHub Actions, can't reproduce locally // ignore is added only for GitHub Actions, can't reproduce locally
goleak.VerifyTestMain( goleak.VerifyTestMain(
m, m,
// the shutdown goroutine in serverApp.run is not joined by Wait, and Rest.Shutdown gives
// httpServer.Shutdown a second, which can outlast goleak's retry budget on a loaded runner
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"), goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159 // this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"), goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
// it ends on its own, but a binary that finishes inside that window is reported as leaking
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
) )
} }
+34 -26
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"math/rand"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -25,7 +24,7 @@ func Test_Main(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp", os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"} "--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"}
@@ -48,7 +47,7 @@ func Test_Main(t *testing.T) {
<-finished <-finished
}() }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port)) resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
@@ -63,9 +62,9 @@ func TestMain_WithWebhook(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
var webhookSent int32 var webhookSent atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
atomic.StoreInt32(&webhookSent, 1) webhookSent.Store(1)
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
b, e := io.ReadAll(r.Body) b, e := io.ReadAll(r.Body)
@@ -76,7 +75,7 @@ func TestMain_WithWebhook(t *testing.T) {
})) }))
defer ts.Close() defer ts.Close()
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp", os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg", "--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg",
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"} "--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
@@ -107,7 +106,7 @@ func TestMain_WithWebhook(t *testing.T) {
<-finished <-finished
}() }()
waitForHTTPServerStart(port) waitForHTTPServerStart(t, port)
resp, err := http.Post(fmt.Sprintf("http://admin:password@localhost:%d/api/v1/comment", port), "", resp, err := http.Post(fmt.Sprintf("http://admin:password@localhost:%d/api/v1/comment", port), "",
strings.NewReader(`{"text": "env test", "locator":{"url": "https://radio-t.com", "site": "remark"}}`)) strings.NewReader(`{"text": "env test", "locator":{"url": "https://radio-t.com", "site": "remark"}}`))
@@ -117,8 +116,8 @@ func TestMain_WithWebhook(t *testing.T) {
// wait for webhook to be sent before shutting down // wait for webhook to be sent before shutting down
assert.Eventually(t, func() bool { assert.Eventually(t, func() bool {
return atomic.LoadInt32(&webhookSent) == int32(1) return webhookSent.Load() == int32(1)
}, time.Second, 100*time.Millisecond, "webhook was not sent") }, 30*time.Second, 10*time.Millisecond, "webhook was not sent")
} }
func TestGetDump(t *testing.T) { func TestGetDump(t *testing.T) {
@@ -129,37 +128,46 @@ func TestGetDump(t *testing.T) {
t.Logf("\n dump: %s", dump) t.Logf("\n dump: %s", dump)
} }
func chooseRandomUnusedPort() (port int) { // chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
for range 10 { // collision between concurrently running package test binaries very unlikely
port = 40000 + int(rand.Int31n(10000)) func chooseUnusedPort(t *testing.T) int {
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil { t.Helper()
_ = ln.Close() ln, err := net.Listen("tcp", ":0")
break require.NoError(t, err, "no free port available")
} port := ln.Addr().(*net.TCPAddr).Port
} require.NoError(t, ln.Close())
return port return port
} }
func waitForHTTPServerStart(port int) { // waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
// wait for up to 10 seconds for server to start before returning it // port if it never does
func waitForHTTPServerStart(t *testing.T, port int) {
t.Helper()
client := http.Client{Timeout: time.Second} client := http.Client{Timeout: time.Second}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
for range 100 { require.Eventually(t, func() bool {
time.Sleep(time.Millisecond * 100) resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil { if err != nil {
_ = resp.Body.Close() return false
return
} }
} _ = resp.Body.Close()
return true
}, 30*time.Second, 10*time.Millisecond, "http server on port %d didn't start", port)
} }
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// both ignores are for leaks which are detected locally // both ignores are for leaks which are detected locally
goleak.VerifyTestMain( goleak.VerifyTestMain(
m, m,
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"), // the shutdown goroutine in serverApp.run is not joined by Wait, and Rest.Shutdown gives
// httpServer.Shutdown a second, which can outlast goleak's retry budget on a loaded runner
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"), goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159 // this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"), goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
// it ends on its own, but a binary that finishes inside that window is reported as leaking
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
) )
} }
+22 -7
View File
@@ -1,6 +1,7 @@
package migrator package migrator
import ( import (
"compress/gzip"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -50,9 +51,7 @@ func TestBackup_MakeBackup(t *testing.T) {
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102")) expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
assert.Equal(t, expFile, fname) assert.Equal(t, expFile, fname)
fi, err := os.Lstat(expFile) assert.Equal(t, exportedPayload, gzContent(t, expFile))
assert.NoError(t, err)
assert.Equal(t, int64(52), fi.Size())
} }
func TestBackup_Do(t *testing.T) { func TestBackup_Do(t *testing.T) {
@@ -71,15 +70,31 @@ func TestBackup_Do(t *testing.T) {
bk.Do(ctx) bk.Do(ctx)
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102")) expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
fi, err := os.Lstat(expFile) assert.Equal(t, exportedPayload, gzContent(t, expFile))
assert.NoError(t, err)
assert.Equal(t, int64(52), fi.Size())
}) })
} }
const exportedPayload = "some export blah blah 1234567890"
// the compressed size is not assertable: it moves with the compress/flate version
func gzContent(t *testing.T, name string) string {
t.Helper()
fh, err := os.Open(name) //nolint:gosec // path is built by the test
require.NoError(t, err)
defer func() { assert.NoError(t, fh.Close()) }()
gz, err := gzip.NewReader(fh)
require.NoError(t, err)
defer func() { assert.NoError(t, gz.Close()) }()
b, err := io.ReadAll(gz)
require.NoError(t, err)
return string(b)
}
type mockExporter struct{} type mockExporter struct{}
func (mock *mockExporter) Export(w io.Writer, _ string) (int, error) { func (mock *mockExporter) Export(w io.Writer, _ string) (int, error) {
_, err := w.Write([]byte("some export blah blah 1234567890")) _, err := w.Write([]byte(exportedPayload))
return 1000, err return 1000, err
} }
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"slices"
"sync/atomic" "sync/atomic"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
@@ -46,8 +47,8 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
log.Printf("[DEBUG] exporting %d topics", len(topics)) log.Printf("[DEBUG] exporting %d topics", len(topics))
commentsCount := 0 commentsCount := 0
for i := len(topics) - 1; i >= 0; i-- { // topics from List sorted in opposite direction for _, topic := range slices.Backward(topics) { // topics from List sorted in opposite direction
topic := topics[i]
comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser) comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser)
if e != nil { if e != nil {
return commentsCount, e return commentsCount, e
+35 -10
View File
@@ -3,15 +3,16 @@ package notify
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"html/template"
"net/url" "net/url"
"text/template"
"time" "time"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
ntf "github.com/go-pkgz/notify" ntf "github.com/go-pkgz/notify"
"github.com/go-pkgz/repeater/v2" "github.com/go-pkgz/repeater/v2"
"github.com/hashicorp/go-multierror" "github.com/microcosm-cc/bluemonday"
"github.com/umputun/remark42/backend/app/templates" "github.com/umputun/remark42/backend/app/templates"
) )
@@ -42,12 +43,12 @@ type Email struct {
type msgTmplData struct { type msgTmplData struct {
UserName string UserName string
UserPicture string UserPicture string
CommentText string CommentText template.HTML
CommentLink string CommentLink string
CommentDate time.Time CommentDate time.Time
ParentUserName string ParentUserName string
ParentUserPicture string ParentUserPicture string
ParentCommentText string ParentCommentText template.HTML
ParentCommentLink string ParentCommentLink string
ParentCommentDate time.Time ParentCommentDate time.Time
PostTitle string PostTitle string
@@ -56,6 +57,30 @@ type msgTmplData struct {
ForAdmin bool ForAdmin bool
} }
// emailCommentPolicy sanitizes comment HTML for inclusion in notification emails.
// It is intentionally stricter than the store-level UGC policy used for web rendering:
// links (<a>) and images (<img>) are dropped so a comment can't smuggle phishing links
// or remote tracking pixels into an email sent from the legitimate remark42 address,
// while basic inline and block text formatting is preserved.
var emailCommentPolicy = func() *bluemonday.Policy {
p := bluemonday.NewPolicy()
p.AllowElements(
"p", "br", "hr", "div", "span",
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "sub", "sup", "mark", "small",
"blockquote", "q", "cite",
"code", "pre", "kbd", "samp", "var",
"ul", "ol", "li", "dl", "dt", "dd",
"h1", "h2", "h3", "h4", "h5", "h6",
)
return p
}()
// emailSafeHTML strips links and images from pre-rendered comment HTML and returns
// it as template.HTML so html/template renders the remaining safe formatting as-is.
func emailSafeHTML(commentHTML string) template.HTML {
return template.HTML(emailCommentPolicy.Sanitize(commentHTML)) //nolint:gosec // sanitized above: <a>/<img> dropped, only formatting tags survive
}
// verifyTmplData store data for verification message template execution // verifyTmplData store data for verification message template execution
type verifyTmplData struct { type verifyTmplData struct {
User string User string
@@ -135,23 +160,23 @@ func (e *Email) Send(ctx context.Context, req Request) error {
default: default:
} }
result := new(multierror.Error) var errs []error
for _, email := range req.Emails { for _, email := range req.Emails {
err := e.buildAndSendMessage(ctx, req, email, false) err := e.buildAndSendMessage(ctx, req, email, false)
if err != nil { if err != nil {
result = multierror.Append(fmt.Errorf("problem sending user email notification to %q: %w", email, err)) errs = append(errs, fmt.Errorf("problem sending user email notification to %q: %w", email, err))
} }
} }
for _, email := range e.AdminEmails { for _, email := range e.AdminEmails {
err := e.buildAndSendMessage(ctx, req, email, true) err := e.buildAndSendMessage(ctx, req, email, true)
if err != nil { if err != nil {
result = multierror.Append(fmt.Errorf("problem sending admin email notification to %q: %w", email, err)) errs = append(errs, fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
} }
} }
return result.ErrorOrNil() return errors.Join(errs...)
} }
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error { func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
@@ -257,7 +282,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
tmplData := msgTmplData{ tmplData := msgTmplData{
UserName: req.Comment.User.Name, UserName: req.Comment.User.Name,
UserPicture: req.Comment.User.Picture, UserPicture: req.Comment.User.Picture,
CommentText: req.Comment.Text, CommentText: emailSafeHTML(req.Comment.Text),
CommentLink: commentURLPrefix + req.Comment.ID, CommentLink: commentURLPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp, CommentDate: req.Comment.Timestamp,
PostTitle: req.Comment.PostTitle, PostTitle: req.Comment.PostTitle,
@@ -269,7 +294,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
if req.Comment.ParentID != "" { if req.Comment.ParentID != "" {
tmplData.ParentUserName = req.parent.User.Name tmplData.ParentUserName = req.parent.User.Name
tmplData.ParentUserPicture = req.parent.User.Picture tmplData.ParentUserPicture = req.parent.User.Picture
tmplData.ParentCommentText = req.parent.Text tmplData.ParentCommentText = emailSafeHTML(req.parent.Text)
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
tmplData.ParentCommentDate = req.parent.Timestamp tmplData.ParentCommentDate = req.parent.Timestamp
} }
+56 -6
View File
@@ -3,8 +3,8 @@ package notify
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"testing" "testing"
"text/template"
ntf "github.com/go-pkgz/notify" ntf "github.com/go-pkgz/notify"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -110,10 +110,10 @@ func TestEmailSendErrors(t *testing.T) {
e.msgTmpl, err = template.New("test").Parse("{{.Test}}") e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
assert.NoError(t, err) assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}), assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\": "+ "problem sending user email notification to \"bad@example.org\": "+
"error executing template to build comment reply message: "+ "error executing template to build comment reply message: "+
"template: test:1:2: executing \"test\" at <.Test>: "+ "template: test:1:2: executing \"test\" at <.Test>: "+
"can't evaluate field Test in type notify.msgTmplData\n\n") "can't evaluate field Test in type notify.msgTmplData")
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -121,8 +121,14 @@ func TestEmailSendErrors(t *testing.T) {
"sending email messages about comment \"999\" aborted due to canceled context") "sending email messages about comment \"999\" aborted due to canceled context")
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad@example.org"}}), assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad@example.org"}}),
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\":"+ "problem sending user email notification to \"bad@example.org\":"+
" error creating token for unsubscribe link: token generation error\n\n") " error creating token for unsubscribe link: token generation error")
// errors for all failed recipients are reported, not just the last one
assert.EqualError(t, e.Send(context.Background(),
Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad1@example.org", "bad2@example.org"}}),
"problem sending user email notification to \"bad1@example.org\": error creating token for unsubscribe link: token generation error\n"+
"problem sending user email notification to \"bad2@example.org\": error creating token for unsubscribe link: token generation error")
} }
func TestEmailSend_ExitConditions(t *testing.T) { func TestEmailSend_ExitConditions(t *testing.T) {
@@ -164,7 +170,7 @@ User: test_user
01.01.0001 at 00:00 01.01.0001 at 00:00
Comment: Comment:
test@example.org for parent_user test@example.org for parent_user
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&amp;tkn=token
`, msg.body) `, msg.body)
assert.Equal(t, "https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token", msg.unsubscribeLink) assert.Equal(t, "https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token", msg.unsubscribeLink)
assert.Equal(t, `New reply to your comment for "test_title"`, msg.subject) assert.Equal(t, `New reply to your comment for "test_title"`, msg.subject)
@@ -190,6 +196,50 @@ admin@example.org
assert.Empty(t, msg.unsubscribeLink) assert.Empty(t, msg.unsubscribeLink)
} }
func TestEmail_CommentTextSanitizedForEmail(t *testing.T) {
// comment HTML reaching the email path is sanitized by the store-level UGC policy,
// which permits <a> and <img>. The email must drop both so a comment can't inject
// phishing links or remote tracking pixels into a notification (GHSA-74pc-3r2m-ppx3).
email, err := NewEmail(EmailParams{
From: "from@example.org",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, ntf.SMTPParams{})
require.NoError(t, err)
email.TokenGenFn = TokenGenFn
malicious := `hello <a href="https://phishing.example/verify">click to verify</a>` +
` <img src="https://attacker.example/track.png" width="1" height="1"> <b>kept</b>`
req := Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title", Text: malicious},
Emails: []string{"test@example.org"},
}
msg, err := email.buildMessageFromRequest(req, req.Emails[0], false)
require.NoError(t, err)
assert.NotContains(t, msg.body, "phishing.example", "phishing link must be stripped")
assert.NotContains(t, msg.body, "attacker.example", "tracking pixel must be stripped")
assert.NotContains(t, msg.body, "<img", "no image tags in email body")
assert.NotContains(t, msg.body, "<a ", "no anchor tags in email body")
assert.Contains(t, msg.body, "click to verify", "anchor text is preserved, only the link is dropped")
assert.Contains(t, msg.body, "<b>kept</b>", "basic formatting is preserved")
}
// emailSafeHTML drops links/images while keeping inline/block formatting and escaping nothing extra.
func TestEmailSafeHTML(t *testing.T) {
tbl := []struct{ name, in, want string }{
{"strips anchor keeps text", `<a href="http://evil">x</a>`, "x"},
{"strips image entirely", `a<img src="http://evil/t.png">b`, "ab"},
{"keeps bold/italic/code", `<b>b</b><i>i</i><code>c</code>`, `<b>b</b><i>i</i><code>c</code>`},
{"keeps blockquote and lists", `<blockquote>q</blockquote><ul><li>x</li></ul>`, `<blockquote>q</blockquote><ul><li>x</li></ul>`},
{"drops onclick handlers", `<span onclick="alert(1)">s</span>`, `<span>s</span>`},
}
for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, string(emailSafeHTML(tt.in)))
})
}
}
func TestEmail_SendVerification(t *testing.T) { func TestEmail_SendVerification(t *testing.T) {
email, err := NewEmail(EmailParams{ email, err := NewEmail(EmailParams{
From: "from@example.org", From: "from@example.org",
+4 -4
View File
@@ -19,7 +19,7 @@ type Service struct {
queue chan Request queue chan Request
verificationQueue chan VerificationRequest verificationQueue chan VerificationRequest
closed uint32 // non-zero means closed. uses uint instead of bool for atomic closed atomic.Uint32 // non-zero means closed. uses uint instead of bool for atomic
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
} }
@@ -83,7 +83,7 @@ func NewService(dataService Store, size int, destinations ...Destination) *Servi
// Submit Request to internal channel if not busy, drop if can't send // Submit Request to internal channel if not busy, drop if can't send
func (s *Service) Submit(req Request) { func (s *Service) Submit(req Request) {
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 { if len(s.destinations) == 0 || s.closed.Load() != 0 {
return return
} }
if s.dataService != nil && req.Comment.ParentID != "" { if s.dataService != nil && req.Comment.ParentID != "" {
@@ -130,7 +130,7 @@ func (s *Service) getNotificationTargets(
// SubmitVerification to internal channel if not busy, drop if can't send // SubmitVerification to internal channel if not busy, drop if can't send
func (s *Service) SubmitVerification(req VerificationRequest) { func (s *Service) SubmitVerification(req VerificationRequest) {
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 { if len(s.destinations) == 0 || s.closed.Load() != 0 {
return return
} }
select { select {
@@ -155,7 +155,7 @@ func (s *Service) Close() {
s.cancel() s.cancel()
<-s.ctx.Done() <-s.ctx.Done()
} }
atomic.StoreUint32(&s.closed, 1) s.closed.Store(1)
} }
func (s *Service) do() { func (s *Service) do() {
+7
View File
@@ -15,10 +15,14 @@ type MockDest struct {
id int id int
closed bool closed bool
lock sync.Mutex lock sync.Mutex
block chan struct{} // if non-nil, Send/SendVerification wait on it before recording, letting tests pin the consumer
} }
// Send mock // Send mock
func (m *MockDest) Send(ctx context.Context, r Request) error { func (m *MockDest) Send(ctx context.Context, r Request) error {
if m.block != nil {
<-m.block
}
m.lock.Lock() m.lock.Lock()
defer m.lock.Unlock() defer m.lock.Unlock()
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@@ -33,6 +37,9 @@ func (m *MockDest) Send(ctx context.Context, r Request) error {
// SendVerification mock // SendVerification mock
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error { func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
if m.block != nil {
<-m.block
}
m.lock.Lock() m.lock.Lock()
defer m.lock.Unlock() defer m.lock.Unlock()
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
+30 -14
View File
@@ -2,7 +2,6 @@ package notify
import ( import (
"fmt" "fmt"
"sync/atomic"
"testing" "testing"
"testing/synctest" "testing/synctest"
@@ -49,26 +48,38 @@ func TestService_WithDestinations(t *testing.T) {
func TestService_WithDrops(t *testing.T) { func TestService_WithDrops(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2} // gated destinations pin the consumer on the first item so the size-1 queue
// fills deterministically and the overflow is dropped regardless of scheduling
gate := make(chan struct{})
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
s := NewService(nil, 1, d1, d2) s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s) assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "100"}}) s.Submit(Request{Comment: store.Comment{ID: "100"}}) // consumed, consumer blocks in Send on the gate
s.Submit(Request{Comment: store.Comment{ID: "101"}}) synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "102"}}) s.Submit(Request{Comment: store.Comment{ID: "101"}}) // fills the size-1 queue
s.Submit(Request{Comment: store.Comment{ID: "102"}}) // queue full, dropped
synctest.Wait()
close(gate) // release the consumer: it finishes 100 then processes 101
synctest.Wait() synctest.Wait()
s.Close() s.Close()
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
assert.LessOrEqual(t, len(d1.Get()), 2, "at least one comment from three dropped from d1, got: %v", d1.Get()) require.Len(t, d1.Get(), 2, "one comment of three dropped from d1, got: %v", d1.Get())
assert.LessOrEqual(t, len(d2.Get()), 2, "at least one comment from three dropped from d2, got: %v", d2.Get()) require.Len(t, d2.Get(), 2, "one comment of three dropped from d2, got: %v", d2.Get())
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
}) })
} }
func TestService_SubmitVerificationWithDrops(t *testing.T) { func TestService_SubmitVerificationWithDrops(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2} // gated destinations pin the consumer on the first item so the size-1 queue
// fills deterministically and the overflow is dropped regardless of scheduling
gate := make(chan struct{})
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
s := NewService(nil, 1, d1, d2) s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s) assert.NotNil(t, s)
@@ -77,22 +88,27 @@ func TestService_SubmitVerificationWithDrops(t *testing.T) {
User: "testUser", User: "testUser",
Email: "test@example.org", Email: "test@example.org",
Token: "testToken", Token: "testToken",
}) }) // consumed, consumer blocks in SendVerification on the gate
s.SubmitVerification(VerificationRequest{}) synctest.Wait()
s.SubmitVerification(VerificationRequest{}) s.SubmitVerification(VerificationRequest{User: "second"}) // fills the size-1 queue
s.SubmitVerification(VerificationRequest{User: "dropped"}) // queue full, dropped
synctest.Wait()
close(gate) // release the consumer: it finishes testUser then processes second
synctest.Wait() synctest.Wait()
s.Close() s.Close()
s.SubmitVerification(VerificationRequest{}) // safe to send after close s.SubmitVerification(VerificationRequest{}) // safe to send after close
assert.LessOrEqual(t, len(d2.GetVerify()), 2, "one request from three dropped from d2, got: %v", d2.GetVerify()) require.Len(t, d2.GetVerify(), 2, "one request of three dropped from d2, got: %v", d2.GetVerify())
verifyDest := d1.GetVerify() verifyDest := d1.GetVerify()
require.LessOrEqual(t, len(verifyDest), 2, "one request from three dropped from d1, got: %v", verifyDest) require.Len(t, verifyDest, 2, "one request of three dropped from d1, got: %v", verifyDest)
assert.Equal(t, "remark", verifyDest[0].SiteID) assert.Equal(t, "remark", verifyDest[0].SiteID)
assert.Equal(t, "testUser", verifyDest[0].User) assert.Equal(t, "testUser", verifyDest[0].User)
assert.Equal(t, "test@example.org", verifyDest[0].Email) assert.Equal(t, "test@example.org", verifyDest[0].Email)
assert.Equal(t, "testToken", verifyDest[0].Token) assert.Equal(t, "testToken", verifyDest[0].Token)
assert.Equal(t, "second", verifyDest[1].User)
}) })
} }
@@ -281,7 +297,7 @@ func TestService_Nop(t *testing.T) {
s := NopService s := NopService
s.Submit(Request{Comment: store.Comment{}}) s.Submit(Request{Comment: store.Comment{}})
s.Close() s.Close()
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed)) assert.Equal(t, uint32(1), s.closed.Load())
} }
type mockStore struct { type mockStore struct {
+5 -5
View File
@@ -2,12 +2,12 @@ package notify
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
ntf "github.com/go-pkgz/notify" ntf "github.com/go-pkgz/notify"
"github.com/hashicorp/go-multierror"
) )
const commentTextLengthLimit = 100 const commentTextLengthLimit = 100
@@ -47,14 +47,14 @@ func NewTelegram(params TelegramParams) (*Telegram, error) {
// Send to telegram recipients // Send to telegram recipients
func (t *Telegram) Send(ctx context.Context, req Request) error { func (t *Telegram) Send(ctx context.Context, req Request) error {
log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID) log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID)
result := new(multierror.Error) var errs []error
msg := t.buildMessage(req) msg := t.buildMessage(req)
if t.AdminChannelID != "" { if t.AdminChannelID != "" {
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", t.AdminChannelID), msg) err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", t.AdminChannelID), msg)
if err != nil { if err != nil {
result = multierror.Append(result, errs = append(errs,
fmt.Errorf("problem sending admin telegram notification about comment ID %s to %s: %w", fmt.Errorf("problem sending admin telegram notification about comment ID %s to %s: %w",
req.Comment.ID, t.AdminChannelID, err, req.Comment.ID, t.AdminChannelID, err,
), ),
@@ -66,7 +66,7 @@ func (t *Telegram) Send(ctx context.Context, req Request) error {
for _, user := range req.Telegrams { for _, user := range req.Telegrams {
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", user), msg) err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", user), msg)
if err != nil { if err != nil {
result = multierror.Append(result, errs = append(errs,
fmt.Errorf("problem sending user telegram notification about comment ID %s to %q: %w", fmt.Errorf("problem sending user telegram notification about comment ID %s to %q: %w",
req.Comment.ID, user, err, req.Comment.ID, user, err,
), ),
@@ -74,7 +74,7 @@ func (t *Telegram) Send(ctx context.Context, req Request) error {
} }
} }
} }
return result.ErrorOrNil() return errors.Join(errs...)
} }
// buildMessage generates message for generic notification about new comment // buildMessage generates message for generic notification about new comment
-1
View File
@@ -30,7 +30,6 @@ func TestTelegram_Send(t *testing.T) {
err := tb.Send(context.Background(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}}) err := tb.Send(context.Background(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "2 errors occurred")
assert.Contains(t, err.Error(), "problem sending user telegram notification about comment ID 999 to \"test_user_channel\"") assert.Contains(t, err.Error(), "problem sending user telegram notification about comment ID 999 to \"test_user_channel\"")
assert.Contains(t, err.Error(), "problem sending admin telegram notification about comment ID 999 to remark_test") assert.Contains(t, err.Error(), "problem sending admin telegram notification about comment ID 999 to remark_test")
+31 -13
View File
@@ -1,13 +1,15 @@
package api package api
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"path" "path"
"strings"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/go-pkgz/auth/v2" "github.com/go-pkgz/auth/v2"
"github.com/go-pkgz/auth/v2/avatar"
cache "github.com/go-pkgz/lcw/v2" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
@@ -43,7 +45,7 @@ type adminStore interface {
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment // DELETE /comment/{id}?site=siteID&url=post-url - removes comment
func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := r.PathValue("id")
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
log.Printf("[INFO] delete comment %s", id) log.Printf("[INFO] delete comment %s", id)
@@ -58,7 +60,7 @@ func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid // DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userid") userID := r.PathValue("userid")
siteID := r.URL.Query().Get("site") siteID := r.URL.Query().Get("site")
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID) log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
@@ -72,7 +74,7 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
// GET /user/{userid}?site=side-id - get user info for requested userid // GET /user/{userid}?site=side-id - get user info for requested userid
func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userid") userID := r.PathValue("userid")
siteID := r.URL.Query().Get("site") siteID := r.URL.Query().Get("site")
log.Printf("[INFO] get user info for %s, site %s", userID, siteID) log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
@@ -123,10 +125,15 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
} }
if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil { if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil {
avatarStore := a.authenticator.AvatarProxy().Store if avatarID := avatarIDFromPicture(claims.User.Picture); avatarID != "" {
if err = avatarStore.Remove(path.Base(claims.User.Picture)); err != nil { // an already-removed avatar is fine (a repeated request stays idempotent), but a genuine
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar", rest.ErrInternal) // store failure is surfaced now that avatar.ErrNotFound lets us tell the two apart
return if err = a.authenticator.AvatarProxy().Store.Remove(avatarID); err != nil && !errors.Is(err, avatar.ErrNotFound) {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete user's avatar", rest.ErrInternal)
return
}
} else {
log.Printf("[WARN] unexpected avatar picture %q for user %s on site %s, skipping removal", claims.User.Picture, claims.User.ID, audience)
} }
} }
@@ -134,9 +141,20 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
R.RenderJSON(w, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience}) R.RenderJSON(w, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
} }
// avatarIDFromPicture returns the avatar-store object id for a user picture, or "" if the picture
// does not resolve to a well-formed id (the store names its objects "<hash>.image"). Guarding on the
// id shape keeps a malformed picture, e.g. a path sentinel, from making a filesystem-backed store
// target an unexpected path.
func avatarIDFromPicture(picture string) string {
if id := path.Base(picture); strings.HasSuffix(id, ".image") {
return id
}
return ""
}
// PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user // PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user
func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userid") userID := r.PathValue("userid")
siteID := r.URL.Query().Get("site") siteID := r.URL.Query().Get("site")
blockStatus := r.URL.Query().Get("block") == "1" blockStatus := r.URL.Query().Get("block") == "1"
@@ -202,7 +220,7 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
// PUT /title/{id}?site=siteID&url=post-url - set comment PostTitle to page's title // PUT /title/{id}?site=siteID&url=post-url - set comment PostTitle to page's title
func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := r.PathValue("id")
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
c, err := a.dataService.SetTitle(locator, id) c, err := a.dataService.SetTitle(locator, id)
@@ -216,9 +234,9 @@ func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
R.RenderJSON(w, R.JSON{"id": id, "locator": locator}) R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
} }
// PUT /verify?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post // PUT /verify/{userid}?site=siteID&verified=1 - set or reset verified status for the user
func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userid") userID := r.PathValue("userid")
siteID := r.URL.Query().Get("site") siteID := r.URL.Query().Get("site")
verifyStatus := r.URL.Query().Get("verified") == "1" verifyStatus := r.URL.Query().Get("verified") == "1"
@@ -233,7 +251,7 @@ func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
// PUT /pin/{id}?site=siteID&url=post-url&pin=1 // PUT /pin/{id}?site=siteID&url=post-url&pin=1
// mark/unmark comment as a special // mark/unmark comment as a special
func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
commentID := chi.URLParam(r, "id") commentID := r.PathValue("id")
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
pinStatus := r.URL.Query().Get("pin") == "1" pinStatus := r.URL.Query().Get("pin") == "1"
+192 -42
View File
@@ -62,7 +62,7 @@ func TestAdmin_Delete(t *testing.T) {
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody) fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
require.NoError(t, err) require.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -75,14 +75,22 @@ func TestAdmin_Delete(t *testing.T) {
assert.Equal(t, "", cr.Text) assert.Equal(t, "", cr.Text)
assert.True(t, cr.Deleted) assert.True(t, cr.Deleted)
time.Sleep(250 * time.Millisecond) // the last-comments list refreshes asynchronously after the delete. the polling closure runs
// check last comments updated // off the test goroutine, so it asserts on the CollectT it is handed rather than on t, which
res, code = get(t, ts.URL+"/api/v1/last/2?site=remark42") // also puts the real transport or decode error in the failure message
assert.Equal(t, http.StatusOK, code) pollClient := http.Client{Timeout: waitTimeout}
comments = []store.Comment{} defer pollClient.CloseIdleConnections()
err = json.Unmarshal([]byte(res), &comments) require.EventuallyWithT(t, func(c *assert.CollectT) {
assert.NoError(t, err) lastResp, gErr := pollClient.Get(ts.URL + "/api/v1/last/2?site=remark42")
assert.Equal(t, 1, len(comments), "should have 1 comments") if !assert.NoError(c, gErr) {
return
}
defer lastResp.Body.Close()
assert.Equal(c, http.StatusOK, lastResp.StatusCode)
last := []store.Comment{}
assert.NoError(c, json.NewDecoder(lastResp.Body).Decode(&last))
assert.Len(c, last, 1, "should have 1 comments")
}, waitTimeout, httpPoll)
// check count updated // check count updated
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah") res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
@@ -139,7 +147,7 @@ func TestAdmin_Title(t *testing.T) {
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -174,7 +182,7 @@ func TestAdmin_DeleteUser(t *testing.T) {
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), http.NoBody) req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
assert.NoError(t, err) assert.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -275,7 +283,7 @@ func TestAdmin_Block(t *testing.T) {
req, err := http.NewRequest(http.MethodPut, url, http.NoBody) req, err := http.NewRequest(http.MethodPut, url, http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
body, err = io.ReadAll(resp.Body) body, err = io.ReadAll(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
@@ -333,10 +341,12 @@ func TestAdmin_Block(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, false, j["block"]) assert.Equal(t, false, j["block"])
// block with ttl // block with ttl, checked in place rather than through another admin request, which would
// push this test over the 10 req/s limit on that route
makeTwoComments() makeTwoComments()
code, _ = block(1, "50ms") code, _ = block(1, "500ms")
require.Equal(t, http.StatusOK, code) require.Equal(t, http.StatusOK, code)
require.True(t, srv.adminRest.dataService.IsBlocked("remark42", "user1"), "user1 blocked with ttl")
// get as regular user // get as regular user
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
@@ -350,7 +360,13 @@ func TestAdmin_Block(t *testing.T) {
srv.pubRest.cache = cache.NewScache[[]byte](cache.NewNopCache[[]byte]()) // TODO: with lru cache it won't be refreshed and invalidated for long srv.pubRest.cache = cache.NewScache[[]byte](cache.NewNopCache[[]byte]()) // TODO: with lru cache it won't be refreshed and invalidated for long
// time // time
time.Sleep(50 * time.Millisecond)
// the ttl above is wide enough that the checks in between cannot outlast it, so reaching
// here still inside the block, and the wait below observes it lapse
require.Eventually(t, func() bool {
return !srv.adminRest.dataService.IsBlocked("remark42", "user1")
}, waitTimeout, pollInterval, "block with ttl did not expire")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{} comments = commentsWithInfo{}
@@ -383,23 +399,23 @@ func TestAdmin_BlockedList(t *testing.T) {
req, err := http.NewRequest(http.MethodPut, req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody) fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
res, err := sendReq(t, req, adminUmputunToken) res, err := sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, res.Body.Close()) require.NoError(t, res.Body.Close())
assert.Equal(t, http.StatusOK, res.StatusCode) assert.Equal(t, http.StatusOK, res.StatusCode)
// block user2 // block user2 for long enough that the "two users blocked" check below cannot race the ttl
req, err = http.NewRequest(http.MethodPut, req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody) fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=1h", ts.URL, "user2", 1), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken) res, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, res.Body.Close()) require.NoError(t, res.Body.Close())
assert.Equal(t, http.StatusOK, res.StatusCode) assert.Equal(t, http.StatusOK, res.StatusCode)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody) req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
require.NoError(t, err) require.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken) res, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, http.StatusOK, res.StatusCode)
users := []store.BlockedUser{} users := []store.BlockedUser{}
@@ -412,18 +428,33 @@ func TestAdmin_BlockedList(t *testing.T) {
assert.Equal(t, "user2", users[1].ID) assert.Equal(t, "user2", users[1].ID)
assert.Equal(t, "user2 name", users[1].Name) assert.Equal(t, "user2 name", users[1].Name)
t.Logf("%+v", users) t.Logf("%+v", users)
time.Sleep(150 * time.Millisecond)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody) // re-block user2 with a short ttl and wait for it to lapse, so the lapse is observed
// independently of the check above
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody)
require.NoError(t, err) require.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken) res, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
users = []store.BlockedUser{}
err = json.NewDecoder(res.Body).Decode(&users)
assert.NoError(t, err)
require.NoError(t, res.Body.Close()) require.NoError(t, res.Body.Close())
assert.Equal(t, 1, len(users), "one user left blocked") require.Equal(t, http.StatusOK, res.StatusCode)
// the closure runs off the test goroutine and asserts on the CollectT it is handed, never on t
require.EventuallyWithT(t, func(c *assert.CollectT) {
blockedReq, reqErr := http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
if !assert.NoError(c, reqErr) {
return
}
blockedResp, sendErr := sendReq(blockedReq, adminUmputunToken)
if !assert.NoError(c, sendErr) {
return
}
defer blockedResp.Body.Close()
assert.Equal(c, http.StatusOK, blockedResp.StatusCode)
blocked := []store.BlockedUser{}
assert.NoError(c, json.NewDecoder(blockedResp.Body).Decode(&blocked))
assert.Len(c, blocked, 1, "one user left blocked")
}, waitTimeout, httpPoll)
} }
func TestAdmin_ReadOnly(t *testing.T) { func TestAdmin_ReadOnly(t *testing.T) {
@@ -448,11 +479,11 @@ func TestAdmin_ReadOnly(t *testing.T) {
req, err := http.NewRequest(http.MethodPut, req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
resp, err := sendReq(t, req, "") // non-admin user resp, err := sendReq(req, "") // non-admin user
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -467,7 +498,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
assert.NoError(t, err, "can't marshal comment %+v", c) assert.NoError(t, err, "can't marshal comment %+v", c)
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", bytes.NewBuffer(b)) req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", bytes.NewBuffer(b))
require.NoError(t, err) require.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
@@ -476,7 +507,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
req, err = http.NewRequest(http.MethodPut, req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -491,7 +522,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
assert.NoError(t, err, "can't marshal comment %+v", c) assert.NoError(t, err, "can't marshal comment %+v", c)
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site="+c.Locator.SiteID, bytes.NewBuffer(b)) req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site="+c.Locator.SiteID, bytes.NewBuffer(b))
require.NoError(t, err) require.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
@@ -506,7 +537,7 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -553,7 +584,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -565,7 +596,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
req, err = http.NewRequest(http.MethodPut, req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
@@ -594,7 +625,7 @@ func TestAdmin_Verify(t *testing.T) {
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -613,7 +644,7 @@ func TestAdmin_Verify(t *testing.T) {
req, err = http.NewRequest(http.MethodPut, req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody) fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -664,7 +695,7 @@ func TestAdmin_ExportFile(t *testing.T) {
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody) req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody)
require.NoError(t, err) require.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -717,7 +748,7 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
}, },
User: &token.User{ User: &token.User{
ID: "user1", ID: "user1",
Picture: "pic.image", Picture: "https://demo.remark42.com/api/v1/avatar/pic.image", // production-shaped URL: removal must path.Base it to the avatar id
Attributes: map[string]any{ Attributes: map[string]any{
"delete_me": true, "delete_me": true,
}, },
@@ -747,6 +778,124 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
email, err = srv.DataService.GetUserEmail("remark42", "user1") email, err = srv.DataService.GetUserEmail("remark42", "user1")
assert.NoError(t, err) assert.NoError(t, err)
assert.Empty(t, email, "user1 email was deleted") assert.Empty(t, email, "user1 email was deleted")
assert.NoFileExists(t, os.TempDir()+"/ava-remark42/42/pic.image", "user's avatar should be removed on deleteme")
}
// a delete_me request whose token carries a picture must still succeed when the avatar is
// already gone from the store: the user data is deleted and a missing avatar is tolerated
func TestAdmin_DeleteMeRequestMissingAvatar(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user3 name", ID: "user3"}}
_, err := srv.DataService.Create(c1)
require.NoError(t, err)
claims := token.Claims{
SessionOnly: true,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"remark42"},
ID: "2345678",
Issuer: "remark42",
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
},
User: &token.User{
ID: "user3",
Picture: "missing.image", // no avatar file exists for this picture in the store
Attributes: map[string]any{
"delete_me": true,
},
},
}
tkn, err := srv.Authenticator.TokenService().Token(claims)
require.NoError(t, err)
client := http.Client{}
defer client.CloseIdleConnections()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode, "a missing avatar must not fail the deletion")
_, err = srv.DataService.User("remark42", "user3", 0, 0, store.User{})
assert.EqualError(t, err, "no comments for user user3 in store", "user3 comments should be deleted")
}
// a genuine (non not-found) avatar-store failure must now surface, not be silently swallowed:
// avatar.ErrNotFound lets deleteMeRequestCtrl tell an already-gone avatar from a real error
func TestAdmin_DeleteMeRequestAvatarRemoveError(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user5 name", ID: "user5"}}
_, err := srv.DataService.Create(c1)
require.NoError(t, err)
// put a non-empty directory where the avatar file is expected, so Store.Remove fails with a real
// error (directory not empty), not os.ErrNotExist - "pic" hashes to partition 42
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42/pic.image", 0o700))
require.NoError(t, os.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image/child", []byte("x"), 0o600))
claims := token.Claims{
SessionOnly: true,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"remark42"},
ID: "4567890",
Issuer: "remark42",
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
},
User: &token.User{
ID: "user5",
Picture: "https://demo.remark42.com/api/v1/avatar/pic.image",
Attributes: map[string]any{
"delete_me": true,
},
},
}
tkn, err := srv.Authenticator.TokenService().Token(claims)
require.NoError(t, err)
client := http.Client{}
defer client.CloseIdleConnections()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "a real avatar-store failure must surface, not be swallowed")
}
func TestAvatarIDFromPicture(t *testing.T) {
tbl := []struct {
name string
picture string
want string
}{
{"local avatar url", "https://demo.remark42.com/api/v1/avatar/cb42ff493ade696d88a3a590f136ae9e34de7c1b.image", "cb42ff493ade696d88a3a590f136ae9e34de7c1b.image"},
{"bare avatar id", "pic.image", "pic.image"},
{"parent sentinel", "https://demo.remark42.com/api/v1/avatar/..", ""},
{"trailing slash", "https://demo.remark42.com/api/v1/avatar/", ""},
{"root", "/", ""},
{"dotdot", "..", ""},
{"empty", "", ""},
{"provider url without image suffix", "https://example.com/pic.png", ""},
}
for _, tc := range tbl {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, avatarIDFromPicture(tc.picture))
})
}
} }
func TestAdmin_DeleteMeRequestFailed(t *testing.T) { func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
@@ -802,7 +951,8 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try bad user // unknown user: deletion is idempotent, so a valid (signed) delete_me token for a user with
// no stored data is a no-op success rather than an error
badClaimsUser := claims badClaimsUser := claims
badClaimsUser.User.ID = "no-such-id" badClaimsUser.User.ID = "no-such-id"
tkn, err = srv.Authenticator.TokenService().Token(badClaimsUser) tkn, err = srv.Authenticator.TokenService().Token(badClaimsUser)
@@ -813,7 +963,7 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
resp, err = client.Do(req) resp, err = client.Do(req)
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.Status) assert.Equal(t, http.StatusOK, resp.StatusCode, resp.Status)
badClaimsUser.User.ID = "provider1_user1" badClaimsUser.User.ID = "provider1_user1"
// try without deleteme flag // try without deleteme flag
+360
View File
@@ -0,0 +1,360 @@
// Package api middleware: request-scoped HTTP middlewares used by the REST router.
package api
import (
"fmt"
"net"
"net/http"
"net/mail"
"regexp"
"strings"
"time"
"github.com/didip/tollbooth/v8"
"github.com/didip/tollbooth/v8/limiter"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store"
)
// ipForwardingHeaders are the request headers R.RealIP derives the client IP from.
var ipForwardingHeaders = []string{"X-Real-IP", "X-Forwarded-For", "CF-Connecting-IP"}
// realIPMiddleware derives the client IP from forwarding headers (X-Real-IP / X-Forwarded-For /
// CF-Connecting-IP) via R.RealIP, but honors those headers only for requests whose direct peer
// is one of the trusted proxies. For any other peer it drops those headers and pins RemoteAddr to
// the real socket IP, so an untrusted client can't spoof the IP that per-IP controls (rate limiting,
// vote dedup, comment IP, anonymous id) and the request log key on.
//
// With no trusted proxies configured it falls back to trusting the headers from any client (the
// historical behavior). That is spoofable by design, so operators running behind a reverse proxy
// should set --trusted-proxy to the proxy's network — see the "trusted proxy" docs.
func realIPMiddleware(trustedProxies []*net.IPNet) func(http.Handler) http.Handler {
if len(trustedProxies) == 0 {
return R.RealIP
}
return func(next http.Handler) http.Handler {
fromTrusted := R.RealIP(next) // rewrites RemoteAddr from the forwarding headers
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
peer := directPeerIP(r.RemoteAddr)
if peer != nil && cidrsContain(trustedProxies, peer) {
fromTrusted.ServeHTTP(w, r) // trusted proxy: honor the forwarding headers
return
}
// untrusted peer: drop the forwarding headers and pin RemoteAddr to the real socket IP,
// so nothing downstream can be fooled by a spoofed header (R.RealIP normalizes
// RemoteAddr to a bare IP for trusted peers; do the same here for consistency)
for _, h := range ipForwardingHeaders {
r.Header.Del(h)
}
if peer != nil {
r.RemoteAddr = peer.String()
}
next.ServeHTTP(w, r)
})
}
}
// directPeerIP extracts the IP from a "host:port" (or bare host) RemoteAddr, or nil if unparseable.
func directPeerIP(remoteAddr string) net.IP {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr // may already be a bare IP with no port
}
return net.ParseIP(host)
}
// TrustsAnyPeer reports whether the trusted-proxy list contains a catch-all (0.0.0.0/0 or ::/0),
// which trusts forwarding headers from every client and re-opens the IP-spoofing bypass.
func TrustsAnyPeer(cidrs []*net.IPNet) bool {
for _, c := range cidrs {
if ones, _ := c.Mask.Size(); ones == 0 {
return true
}
}
return false
}
// cidrsContain reports whether ip falls within any of the CIDRs.
func cidrsContain(cidrs []*net.IPNet, ip net.IP) bool {
for _, c := range cidrs {
if c.Contains(ip) {
return true
}
}
return false
}
// ParseTrustedProxies parses a list of trusted-proxy entries into CIDRs. Each entry may be a CIDR
// (e.g. 172.16.0.0/12) or a bare IP (treated as a single host). Blank entries are skipped; a
// malformed entry is a hard error so a typo can't silently disable proxy trust.
func ParseTrustedProxies(entries []string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !strings.Contains(e, "/") { // bare IP -> single-host CIDR
ip := net.ParseIP(e)
if ip == nil {
return nil, fmt.Errorf("invalid trusted proxy %q", e)
}
// build the network from the normalized IP so a v4-mapped IPv6 (e.g. ::ffff:10.0.0.1)
// yields the intended /32 host, not a huge ::/32 range
bits := 128
if v4 := ip.To4(); v4 != nil {
ip, bits = v4, 32
}
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
continue
}
_, network, err := net.ParseCIDR(e)
if err != nil {
return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", e, err)
}
out = append(out, network)
}
return out, nil
}
// corsMiddleware builds the CORS middleware for the public API. With AllowedOrigins
// "*" and credentials enabled, rest.CORS reflects the request Origin into
// Access-Control-Allow-Origin (rather than a literal "*"), which browsers require
// for credentialed cross-origin requests.
//
// That combination is refused by default upstream, so it has to be asked for by name with
// CorsUnsafeAnyOriginWithCredentials. The wildcard stays because the comment widget is embedded on
// arbitrary third-party sites, which makes the set of origins unknowable. The consequence it carries
// is that 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.
func corsMiddleware() func(http.Handler) http.Handler {
return R.CORS(
R.CorsAllowedOrigins("*"),
R.CorsAllowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"),
R.CorsAllowedHeaders("Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"),
R.CorsExposedHeaders("Authorization"),
R.CorsAllowCredentials(true),
R.CorsUnsafeAnyOriginWithCredentials(true),
R.CorsMaxAge(300),
)
}
// rejectHead rejects HEAD requests with 405, advertising the given allowed methods in
// the Allow header. net/http.ServeMux routes HEAD to a "GET ..." handler, but per RFC
// 9110 GET/HEAD are safe methods; this guard is applied to the few GET routes whose
// handlers mutate state so they cannot be triggered by a (nominally side-effect-free)
// HEAD, preserving the pre-routegroup behavior. allow lists every method the resource
// supports (e.g. "GET" or "GET, POST") so the 405 Allow header is accurate.
func rejectHead(allow string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.Header().Set("Allow", allow)
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
next.ServeHTTP(w, r)
})
}
}
// rejectAnonUser is a middleware rejecting anonymous users
func rejectAnonUser(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if strings.HasPrefix(user.ID, "anonymous_") {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
func matchSiteID(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// skip for basic auth user
if user.Name == "admin" && user.ID == "admin" {
next.ServeHTTP(w, r)
return
}
siteID := r.URL.Query().Get("site")
// require an explicit site so the user.SiteID check below cannot be bypassed
// by simply omitting the query parameter
if siteID == "" || user.SiteID != siteID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// cacheControl is a middleware setting cache expiration. Using url+version as etag
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
etag := func(r *http.Request, version string) string {
s := version + ":" + r.URL.String()
return store.EncodeID(s)
}
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
e := `"` + etag(r, version) + `"`
w.Header().Set("Etag", e)
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, e) {
w.WriteHeader(http.StatusNotModified)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes
// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps
// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages
// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or
// images — none of those should ever execute scripts when rendered, so they get the
// strictest policy available as defense-in-depth against future trust-boundary bugs.
//
// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same
// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition:
// inline; filename="image" (framing the response as a file rather than a renderable
// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and-
// braces: if a future route refactor bypasses this middleware, the image handlers still
// emit the policy.
func apiCSPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", rest.StrictImageCSP)
next.ServeHTTP(w, r)
})
}
// securityHeadersMiddleware sets security-related headers:
// - Content-Security-Policy: controls which resources the browser is allowed to load
// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget
// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type,
// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS
// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin
// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains
// and nothing at all on HTTPS→HTTP downgrades
func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
imgSrc := "*"
if imageProxyEnabled {
imgSrc = "'self'"
}
frameAncestors := "*"
if len(allowedAncestors) > 0 {
frameAncestors = strings.Join(allowedAncestors, " ")
}
// font-src is set to 'none' (no @font-face / no base64 fonts in the bundle).
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
}
// subscribersOnly is a middleware rejecting non-paid_sub users
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if enable {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !user.PaidSub {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// validEmailAuth is a middleware for auth endpoints for email method.
// it rejects login request if user, site or email are suspicious
func validEmailAuth() func(http.Handler) http.Handler {
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/email/login" {
// not email login, skip the check
h.ServeHTTP(w, r)
return
}
if u := r.URL.Query().Get("user"); u != "" {
if !reUser.MatchString(u) {
log.Printf("[WARN] suspicious user rejected: %s", u)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
if a := r.URL.Query().Get("address"); a != "" {
if _, err := mail.ParseAddress(a); err != nil {
log.Printf("[WARN] suspicious address rejected: %s", a)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
if s := r.URL.Query().Get("site"); s != "" {
if !reSite.MatchString(s) {
log.Printf("[WARN] suspicious site rejected: %s", s)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
// tollbooth v8 requires explicit IP lookup method to be set.
// keys on RemoteAddr, which realIPMiddleware sets to the client IP (from the forwarding
// headers for trusted proxies, otherwise the real socket IP).
func rateLimiter(maxReq float64) func(http.Handler) http.Handler {
lmt := tollbooth.NewLimiter(maxReq, nil)
lmt.SetIPLookup(limiter.IPLookup{
Name: "RemoteAddr",
IndexFromRight: 0,
})
return tollbooth.HTTPMiddleware(lmt)
}
+442
View File
@@ -0,0 +1,442 @@
package api
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/go-pkgz/auth/v2/token"
R "github.com/go-pkgz/rest"
"github.com/go-pkgz/routegroup"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store"
)
// routes() wraps bounded routes with the enforcing rest.Timeout and deliberately leaves the
// streaming/long-polling routes (GET /export, /userdata, /wait) without it. This checks that
// contract holds against the vendored middleware: a slow handler under R.Timeout is aborted with
// 504 at the deadline, while a route left without it runs to completion.
func TestRouteTimeout(t *testing.T) {
slow := func(d time.Duration) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done(): // return promptly once the enforcing timeout cancels the context
case <-time.After(d):
}
w.WriteHeader(http.StatusOK)
}
}
router := routegroup.New(http.NewServeMux())
router.With(R.Timeout(20*time.Millisecond)).HandleFunc("GET /bounded", slow(time.Second))
router.HandleFunc("GET /streaming", slow(30*time.Millisecond)) // no timeout, like /export and /wait
ts := httptest.NewServer(router)
defer ts.Close()
resp, err := http.Get(ts.URL + "/bounded")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode, "route under R.Timeout is aborted at the deadline")
resp, err = http.Get(ts.URL + "/streaming")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode, "route without R.Timeout runs to completion")
}
// TestRateLimiter covers the middleware guarding every route group: a burst past the per-second
// allowance is refused with 429, and a client under the allowance is not. The limiter keys on
// RemoteAddr, so the two cases use different ones rather than waiting for a bucket to refill.
func TestRateLimiter(t *testing.T) {
router := routegroup.New(http.NewServeMux())
router.With(rateLimiter(1)).HandleFunc("GET /limited", func(http.ResponseWriter, *http.Request) {})
ts := httptest.NewServer(router)
defer ts.Close()
call := func(remoteAddr string) int {
req := httptest.NewRequest("GET", "http://example.com/limited", http.NoBody)
req.RemoteAddr = remoteAddr
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
resp := w.Result()
assert.NoError(t, resp.Body.Close())
return resp.StatusCode
}
// one request a second is allowed, so the first of a burst passes and the rest are refused
assert.Equal(t, http.StatusOK, call("1.2.3.4:1000"), "first request within the allowance")
refused := 0
for range 5 {
if call("1.2.3.4:1000") == http.StatusTooManyRequests {
refused++
}
}
assert.Equal(t, 5, refused, "burst past the allowance is refused")
// a different client has its own bucket and is unaffected
assert.Equal(t, http.StatusOK, call("5.6.7.8:1000"), "limit is per client, not global")
}
func TestRealIPMiddleware(t *testing.T) {
// call runs mw with the given peer and (optional) X-Real-IP header and returns what the
// downstream handler observes; state is per-call, so subtests don't share closure locals.
call := func(mw func(http.Handler) http.Handler, remoteAddr, xRealIP string) (addr, hdr string) {
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
addr, hdr = r.RemoteAddr, r.Header.Get("X-Real-IP")
})
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req.RemoteAddr = remoteAddr
if xRealIP != "" {
req.Header.Set("X-Real-IP", xRealIP)
}
mw(next).ServeHTTP(httptest.NewRecorder(), req)
return addr, hdr
}
trusted, err := ParseTrustedProxies([]string{"172.16.0.0/12", "2001:db8::/32"})
require.NoError(t, err)
t.Run("no trusted proxies trusts the header from anyone (legacy)", func(t *testing.T) {
addr, _ := call(realIPMiddleware(nil), "203.0.113.9:1234", "8.8.8.8")
assert.Equal(t, "8.8.8.8", addr)
})
t.Run("trusted v4 peer: forwarding header sets the client IP", func(t *testing.T) {
addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "8.8.8.8")
assert.Equal(t, "8.8.8.8", addr)
})
t.Run("trusted v6 peer: forwarding header honored", func(t *testing.T) {
addr, _ := call(realIPMiddleware(trusted), "[2001:db8::5]:5555", "8.8.8.8")
assert.Equal(t, "8.8.8.8", addr)
})
t.Run("trusted peer without a forwarding header falls back to the socket IP", func(t *testing.T) {
addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "")
assert.Equal(t, "172.18.0.5", addr, "no header to honor, so the bare socket IP is used")
})
t.Run("untrusted peer: header stripped, RemoteAddr pinned to bare socket IP", func(t *testing.T) {
addr, hdr := call(realIPMiddleware(trusted), "203.0.113.9:1234", "8.8.8.8")
assert.Equal(t, "203.0.113.9", addr, "real socket IP with the port stripped")
assert.Empty(t, hdr, "spoofed forwarding header removed so nothing downstream can read it")
})
t.Run("unparseable RemoteAddr is treated as untrusted, header stripped", func(t *testing.T) {
addr, hdr := call(realIPMiddleware(trusted), "garbage", "8.8.8.8")
assert.Equal(t, "garbage", addr, "unparseable peer left as-is, not overwritten")
assert.Empty(t, hdr, "forwarding header still stripped for a non-trusted peer")
})
}
func TestParseTrustedProxies(t *testing.T) {
t.Run("cidr, bare v4, bare v6, blanks", func(t *testing.T) {
got, err := ParseTrustedProxies([]string{"172.16.0.0/12", " 10.0.0.1 ", "", "2001:db8::/32"})
require.NoError(t, err)
require.Len(t, got, 3)
assert.True(t, got[0].Contains(net.ParseIP("172.18.0.5")))
assert.True(t, got[1].Contains(net.ParseIP("10.0.0.1")))
assert.False(t, got[1].Contains(net.ParseIP("10.0.0.2")), "a bare IP is a single host")
assert.True(t, got[2].Contains(net.ParseIP("2001:db8::1")))
})
t.Run("v4-mapped IPv6 bare entry resolves to the v4 host", func(t *testing.T) {
got, err := ParseTrustedProxies([]string{"::ffff:10.0.0.1"})
require.NoError(t, err)
require.Len(t, got, 1)
assert.True(t, got[0].Contains(net.ParseIP("10.0.0.1")), "the intended /32 host")
assert.False(t, got[0].Contains(net.ParseIP("10.0.0.2")), "not a wider range")
})
t.Run("malformed entry is a hard error", func(t *testing.T) {
_, err := ParseTrustedProxies([]string{"172.16.0.0/12", "nonsense"})
require.Error(t, err)
_, err = ParseTrustedProxies([]string{"10.0.0.0/999"})
require.Error(t, err)
})
t.Run("all blank yields nil", func(t *testing.T) {
got, err := ParseTrustedProxies([]string{"", " "})
require.NoError(t, err)
assert.Empty(t, got)
})
}
func TestTrustsAnyPeer(t *testing.T) {
catchAll := func(entries ...string) bool {
cidrs, err := ParseTrustedProxies(entries)
require.NoError(t, err)
return TrustsAnyPeer(cidrs)
}
assert.True(t, catchAll("10.0.0.0/8", "0.0.0.0/0"), "v4 catch-all")
assert.True(t, catchAll("::/0"), "v6 catch-all")
assert.False(t, catchAll("172.16.0.0/12", "10.0.0.5"), "scoped ranges are not catch-all")
assert.False(t, catchAll(), "empty is not catch-all")
}
func TestRest_rejectAnonUser(t *testing.T) {
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "Hello")
}))))
defer ts.Close()
resp, err := http.Get(ts.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in")
resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected")
resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode, "real user")
}
func TestRest_cacheControl(t *testing.T) {
tbl := []struct {
url string
version string
exp time.Duration
etag string
maxAge int
}{
{"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600},
{"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000},
{"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600},
{"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, http.NoBody)
w := httptest.NewRecorder()
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
t.Logf("%+v", resp.Header)
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
})
}
}
// TestRest_apiCSP locks in that /api/v1/* responses get a strict default-src 'none'
// override regardless of what the global CSP allows. The widget HTML pages
// (/web/*.html) still get the global CSP (with 'unsafe-inline' for bootstrap),
// so the test asserts the two policies diverge across origins.
func TestRest_apiCSP(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
client := http.Client{}
// JSON API endpoint — must carry the strict policy
resp, err := client.Get(ts.URL + "/api/v1/config")
require.NoError(t, err)
defer resp.Body.Close()
csp := resp.Header.Get("Content-Security-Policy")
assert.Contains(t, csp, "default-src 'none'",
"API responses must override the global CSP with default-src 'none'; got %q", csp)
assert.Contains(t, csp, "sandbox", "API CSP must include sandbox; got %q", csp)
assert.NotContains(t, csp, "'unsafe-inline'",
"API CSP must not allow inline scripts/styles; got %q", csp)
// RSS/XML endpoint — same strict policy, and the XML response itself must still be served
respRSS, err := client.Get(ts.URL + "/api/v1/rss/site?site=remark42")
require.NoError(t, err)
defer respRSS.Body.Close()
assert.Equal(t, http.StatusOK, respRSS.StatusCode, "RSS must still respond OK under strict CSP")
cspRSS := respRSS.Header.Get("Content-Security-Policy")
assert.Contains(t, cspRSS, "default-src 'none'", "RSS responses must carry the strict API CSP")
assert.Contains(t, cspRSS, "sandbox", "RSS CSP must include sandbox")
// widget HTML — must keep the global CSP (unchanged, lax to support inline bootstrap)
resp2, err := client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
defer resp2.Body.Close()
csp2 := resp2.Header.Get("Content-Security-Policy")
assert.Contains(t, csp2, "'unsafe-inline'",
"widget HTML CSP must keep unsafe-inline for bootstrap; got %q", csp2)
}
// check CSP, img-src should be 'self' with proxy enabled and * without it
func TestRest_securityHeaders(t *testing.T) {
ts, _, teardown := startupT(t)
// with proxy disabled
client := http.Client{}
resp, err := client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;")
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
// until the test ends, so the body has to be released before the server is torn down here
require.NoError(t, resp.Body.Close())
client.CloseIdleConnections()
teardown()
// check CSP with proxy enabled
ts, _, teardown = startupT(t, func(srv *Rest) {
srv.ExternalImageProxy = true
})
defer teardown()
resp, err = client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src 'self';")
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
}
func TestRest_subscribersOnly(t *testing.T) {
paidSubUser := &token.User{}
paidSubUser.SetPaidSub(true)
tbl := []struct {
subsOnly bool
user token.User
setUser bool
status int
}{
{true, token.User{}, false, http.StatusUnauthorized},
{true, token.User{}, true, http.StatusForbidden},
{false, token.User{}, false, http.StatusOK},
{false, token.User{}, true, http.StatusOK},
{true, *paidSubUser, true, http.StatusOK},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
if tt.setUser {
req = token.SetUserInfo(req, tt.user)
}
w := httptest.NewRecorder()
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, tt.status, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
})
}
}
func Test_validEmailAuth(t *testing.T) {
tbl := []struct {
req string
status int
}{
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK},
{"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusForbidden},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someonelooong+loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", http.StatusForbidden},
{"/auth/twitter/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun+example.com&user=someone", http.StatusForbidden},
{"/auth/email/login?site=bad!site&address=umputun%example.com&user=someone", http.StatusForbidden},
{"/auth/email/login?site=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongsite&address=umputun%example.com&user=someone", http.StatusForbidden},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
w := httptest.NewRecorder()
h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, tt.status, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
})
}
}
// TestRest_matchSiteID reproduces the multi-tenant isolation gap in the matchSiteID
// middleware. Before the fix, the check `if siteID != "" && user.SiteID != siteID`
// silently allowed any authenticated request that omitted the ?site= query param.
// On admin and user-mutation routes this meant the cross-site check was bypassable
// just by dropping the parameter. The fix requires ?site= to be present and to match
// the user's bound site.
func TestRest_matchSiteID(t *testing.T) {
wrapped := matchSiteID(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
cases := []struct {
name string
userSite string
query string
want int
}{
{name: "matching site allowed", userSite: "site-a", query: "?site=site-a", want: http.StatusOK},
{name: "mismatched site forbidden", userSite: "site-a", query: "?site=site-b", want: http.StatusForbidden},
{name: "missing site param rejected", userSite: "site-a", query: "", want: http.StatusForbidden},
{name: "empty site param rejected", userSite: "site-a", query: "?site=", want: http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = rest.SetUserInfo(r, store.User{ID: "u", Name: "u", SiteID: c.userSite})
wrapped.ServeHTTP(w, r)
})
ts := httptest.NewServer(h)
defer ts.Close()
resp, err := http.Get(ts.URL + c.query)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, c.want, resp.StatusCode)
})
}
}
func TestCorsMiddleware(t *testing.T) {
h := corsMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Run("credentialed cross-origin reflects the request origin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req.Header.Set("Origin", "https://example.com")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
// AllowedOrigins "*" with credentials must reflect the origin, never a literal "*"
assert.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
assert.Equal(t, "Authorization", rec.Header().Get("Access-Control-Expose-Headers"))
})
t.Run("preflight advertises configured methods, headers and max-age", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/", http.NoBody)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "POST")
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "X-JWT")
assert.Equal(t, "300", rec.Header().Get("Access-Control-Max-Age"))
// preflight responses must vary on origin and the request method/headers so caches
// don't reuse one preflight across different requests
vary := rec.Header().Values("Vary")
assert.Contains(t, vary, "Origin")
assert.Contains(t, vary, "Access-Control-Request-Method")
assert.Contains(t, vary, "Access-Control-Request-Headers")
})
t.Run("same-origin request (no Origin) gets no CORS headers", func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", http.NoBody))
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
})
}
+48 -12
View File
@@ -4,11 +4,13 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -18,6 +20,7 @@ import (
"github.com/umputun/remark42/backend/app/migrator" "github.com/umputun/remark42/backend/app/migrator"
"github.com/umputun/remark42/backend/app/rest" "github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store/engine"
) )
// Migrator rest with import and export controllers // Migrator rest with import and export controllers
@@ -74,21 +77,40 @@ func (m *Migrator) importFormCtrl(w http.ResponseWriter, r *http.Request) {
} }
r.Body = http.MaxBytesReader(w, r.Body, 256*1024*1024) // hard cap on upload to prevent memory exhaustion r.Body = http.MaxBytesReader(w, r.Body, 256*1024*1024) // hard cap on upload to prevent memory exhaustion
if err := r.ParseMultipartForm(20 * 1024 * 1024); err != nil { // 20M max memory, if bigger will make a file reader, err := r.MultipartReader()
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode) rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
return return
} }
file, _, err := r.FormFile("file") tmpfile := ""
if err != nil { for {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get import file from the request", rest.ErrInternal) part, err := reader.NextPart()
return if err == io.EOF {
} break
defer func() { _ = file.Close() }() }
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
return
}
if part.FormName() != "file" {
_ = part.Close()
continue
}
tmpfile, err := m.saveTemp(file) tmpfile, err = m.saveTemp(part)
if err != nil { if closeErr := part.Close(); err == nil && closeErr != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save request to temp file", rest.ErrInternal) err = closeErr
}
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save request to temp file", rest.ErrInternal)
return
}
break
}
if tmpfile == "" {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, fmt.Errorf("file field missing"),
"can't get import file from the request", rest.ErrInternal)
return return
} }
@@ -132,7 +154,8 @@ func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer var buf bytes.Buffer
gzWriter := gzip.NewWriter(&buf) gzWriter := gzip.NewWriter(&buf)
if _, err := m.NativeExporter.Export(gzWriter, siteID); err != nil { if _, err := m.NativeExporter.Export(gzWriter, siteID); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal) code, errCode := exportErrStatus(err)
rest.SendErrorJSON(w, r, code, err, "export failed", errCode)
return return
} }
if err := gzWriter.Close(); err != nil { if err := gzWriter.Close(); err != nil {
@@ -152,10 +175,23 @@ func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
// stream mode - write directly to response // stream mode - write directly to response
if _, err := m.NativeExporter.Export(w, siteID); err != nil { if _, err := m.NativeExporter.Export(w, siteID); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal) code, errCode := exportErrStatus(err)
rest.SendErrorJSON(w, r, code, err, "export failed", errCode)
} }
} }
// exportErrStatus maps an export failure to an HTTP status and error code: an unknown
// site is a client error (400), anything else is treated as internal (500).
// The bolt store returns the engine.ErrSiteNotFound sentinel; the rpc store loses typed
// errors over jrpc, so the "not found" message is matched as a fallback (export only ever
// hits a site-level lookup, so a "not found" here can only mean the site).
func exportErrStatus(err error) (status, errCode int) {
if errors.Is(err, engine.ErrSiteNotFound) || strings.Contains(err.Error(), "not found") {
return http.StatusBadRequest, rest.ErrSiteNotFound
}
return http.StatusInternalServerError, rest.ErrInternal
}
// POST /remap?site=site-id // POST /remap?site=site-id
// remap urls in comments based on given rules (oldUrl newUrl) // remap urls in comments based on given rules (oldUrl newUrl)
func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) { func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
+33 -13
View File
@@ -34,7 +34,7 @@ func TestMigrator_Import(t *testing.T) {
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0, "ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`) "votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
require.NoError(t, err) require.NoError(t, err)
@@ -125,7 +125,7 @@ func TestMigrator_ImportFromWP(t *testing.T) {
r := strings.NewReader(strings.ReplaceAll(xmlTestWP, "'", "`")) r := strings.NewReader(strings.ReplaceAll(xmlTestWP, "'", "`"))
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=wordpress", r) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=wordpress", r)
assert.NoError(t, err) assert.NoError(t, err)
@@ -170,7 +170,7 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
"email":"somegreatmail@gmail.com","name":"User5276","link":"https://example.com/profile/257","photo":"https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7", "email":"somegreatmail@gmail.com","name":"User5276","link":"https://example.com/profile/257","photo":"https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
"provider":"sso:example.com","joinDate":"2021-03-19T19:27:25.954285Z","isModerator":false}]}`) "provider":"sso:example.com","joinDate":"2021-03-19T19:27:25.954285Z","isModerator":false}]}`)
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
assert.NoError(t, err) assert.NoError(t, err)
@@ -211,7 +211,7 @@ func TestMigrator_ImportFromCommentoJSON(t *testing.T) {
r, err := os.Open("testdata/commento.json") r, err := os.Open("testdata/commento.json")
require.NoError(t, err) require.NoError(t, err)
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
assert.NoError(t, err) assert.NoError(t, err)
@@ -258,7 +258,7 @@ func TestMigrator_ImportRejected(t *testing.T) {
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0, "ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`) "votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native&secret=XYZ", r) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native&secret=XYZ", r)
assert.NoError(t, err) assert.NoError(t, err)
@@ -280,10 +280,14 @@ func TestMigrator_ImportDouble(t *testing.T) {
for i := range 50 { for i := range 50 {
recs = append(recs, fmt.Sprintf(tmpl, i)) recs = append(recs, fmt.Sprintf(tmpl, i))
} }
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records // each request needs its own reader. client.Do returns once the response headers are in, which
client := &http.Client{Timeout: 1 * time.Second} // for an accepted import is before the transport's writeLoop has finished copying the body, so
// handing the same strings.Reader to the second NewRequest races that copy: NewRequest reads
// Len() to set ContentLength while WriteTo is still advancing it
body := `{"version":1}` + strings.Join(recs, "\n")
client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
req.SetBasicAuth("admin", "password") req.SetBasicAuth("admin", "password")
assert.NoError(t, err) assert.NoError(t, err)
@@ -294,7 +298,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
client = &http.Client{Timeout: 5 * time.Second} client = &http.Client{Timeout: 5 * time.Second}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r) req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
req.SetBasicAuth("admin", "password") req.SetBasicAuth("admin", "password")
assert.NoError(t, err) assert.NoError(t, err)
@@ -380,7 +384,7 @@ func TestMigrator_Export(t *testing.T) {
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`) "votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
// import comments first // import comments first
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
require.NoError(t, err) require.NoError(t, err)
@@ -391,15 +395,31 @@ func TestMigrator_Export(t *testing.T) {
require.Equal(t, http.StatusAccepted, resp.StatusCode) require.Equal(t, http.StatusAccepted, resp.StatusCode)
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
// export wrong site, should result in error // export unknown site is a client error, not internal
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=test", http.NoBody) req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=test", http.NoBody)
require.NoError(t, err) require.NoError(t, err)
req.SetBasicAuth("admin", "password") req.SetBasicAuth("admin", "password")
resp, err = client.Do(req) resp, err = client.Do(req)
require.NoError(t, err) require.NoError(t, err)
errBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
resp.Body.Close() resp.Body.Close()
require.Equal(t, http.StatusInternalServerError, resp.StatusCode) require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type")) require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
assert.Contains(t, string(errBody), `"code":6`) // rest.ErrSiteNotFound, not ErrInternal
assert.Contains(t, string(errBody), `not found`) // error detail names the missing site
// unknown site in stream mode is also a client error
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=stream&site=test", http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
require.NoError(t, err)
errBody, err = io.ReadAll(resp.Body)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, string(errBody), `"code":6`)
// check file mode // check file mode
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody) req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody)
@@ -557,7 +577,7 @@ func TestMigrator_RemapReject(t *testing.T) {
defer teardown() defer teardown()
// without admin credentials // without admin credentials
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
rules := strings.NewReader(`https://remark42.com/* https://www.remark42.com/*`) rules := strings.NewReader(`https://remark42.com/* https://www.remark42.com/*`)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/remap?site=remark42", rules) req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/remap?site=remark42", rules)
+189 -352
View File
@@ -3,28 +3,22 @@ package api
import ( import (
"bytes" "bytes"
"context" "context"
"embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"net/mail"
"os" "os"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/didip/tollbooth/v8"
"github.com/didip/tollbooth/v8/limiter"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-pkgz/auth/v2" "github.com/go-pkgz/auth/v2"
"github.com/go-pkgz/lcw/v2" "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/go-pkgz/rest/logger" "github.com/go-pkgz/rest/logger"
"github.com/go-pkgz/routegroup"
"github.com/umputun/remark42/backend/app/notify" "github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/rest" "github.com/umputun/remark42/backend/app/rest"
@@ -32,6 +26,7 @@ import (
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/image" "github.com/umputun/remark42/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/service" "github.com/umputun/remark42/backend/app/store/service"
"github.com/umputun/remark42/backend/app/webassets"
) )
// Rest is a rest access server // Rest is a rest access server
@@ -50,10 +45,11 @@ type Rest struct {
AnonVote bool AnonVote bool
WebRoot string WebRoot string
WebFS embed.FS WebFS fs.FS
RemarkURL string RemarkURL string
ReadOnlyAge int ReadOnlyAge int
SharedSecret string SharedSecret string
TrustedProxies []*net.IPNet // reverse-proxy networks whose forwarding headers (X-Real-IP, X-Forwarded-For, ...) are trusted
ScoreThresholds struct { ScoreThresholds struct {
Low int Low int
Critical int Critical int
@@ -71,10 +67,11 @@ type Rest struct {
DisableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments DisableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
ExternalImageProxy bool ExternalImageProxy bool
SSLConfig SSLConfig SSLConfig SSLConfig
httpsServer *http.Server httpsServer *http.Server
httpServer *http.Server httpServer *http.Server
lock sync.Mutex shutdownRequested bool
lock sync.Mutex
pubRest public pubRest public
privRest private privRest private
@@ -117,6 +114,11 @@ func (s *Rest) Run(address string, port int) {
s.lock.Lock() s.lock.Lock()
s.httpServer = s.makeHTTPServer(address, port, s.routes()) s.httpServer = s.makeHTTPServer(address, port, s.routes())
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
if s.shutdownRequested {
s.lock.Unlock()
log.Print("[WARN] rest server start canceled")
return
}
s.lock.Unlock() s.lock.Unlock()
err := s.httpServer.ListenAndServe() err := s.httpServer.ListenAndServe()
@@ -130,6 +132,11 @@ func (s *Rest) Run(address string, port int) {
s.httpServer = s.makeHTTPServer(address, port, s.httpToHTTPSRouter()) s.httpServer = s.makeHTTPServer(address, port, s.httpToHTTPSRouter())
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
if s.shutdownRequested {
s.lock.Unlock()
log.Print("[WARN] rest server start canceled")
return
}
s.lock.Unlock() s.lock.Unlock()
go func() { go func() {
@@ -150,6 +157,11 @@ func (s *Rest) Run(address string, port int) {
s.httpServer = s.makeHTTPServer(address, port, s.httpChallengeRouter(m)) s.httpServer = s.makeHTTPServer(address, port, s.httpChallengeRouter(m))
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
if s.shutdownRequested {
s.lock.Unlock()
log.Print("[WARN] rest server start canceled")
return
}
s.lock.Unlock() s.lock.Unlock()
@@ -171,6 +183,7 @@ func (s *Rest) Shutdown() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second) ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel() defer cancel()
s.lock.Lock() s.lock.Lock()
s.shutdownRequested = true
if s.httpServer != nil { if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctx); err != nil { if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("[DEBUG] http shutdown error, %s", err) log.Printf("[DEBUG] http shutdown error, %s", err)
@@ -198,13 +211,13 @@ func (s *Rest) makeHTTPServer(address string, port int, router http.Handler) *ht
} }
} }
func (s *Rest) routes() chi.Router { func (s *Rest) routes() http.Handler {
if s.openRouteLimiter == 0 { if s.openRouteLimiter == 0 {
// set the default open route limiter. Just a safety measure as it should be set by Run method anyway // set the default open route limiter. Just a safety measure as it should be set by Run method anyway
s.openRouteLimiter = openRouteLimiter s.openRouteLimiter = openRouteLimiter
} }
router := chi.NewRouter() router := routegroup.New(http.NewServeMux())
router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default())) router.Use(R.Throttle(1000), realIPMiddleware(s.TrustedProxies), R.Recoverer(log.Default()))
router.Use(securityHeadersMiddleware(s.ExternalImageProxy, s.AllowedAncestors)) router.Use(securityHeadersMiddleware(s.ExternalImageProxy, s.AllowedAncestors))
if !s.DisableSignature { if !s.DisableSignature {
router.Use(R.AppInfo("remark42", "umputun", s.Version)) router.Use(R.AppInfo("remark42", "umputun", s.Version))
@@ -216,15 +229,7 @@ func (s *Rest) routes() chi.Router {
if s.ProxyCORS { if s.ProxyCORS {
log.Printf("[WARN] internal CORS disabled") log.Printf("[WARN] internal CORS disabled")
} else { } else {
corsMiddleware := cors.New(cors.Options{ router.Use(corsMiddleware())
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"},
ExposedHeaders: []string{"Authorization"},
AllowCredentials: true,
MaxAge: 300,
})
router.Use(corsMiddleware.Handler)
} }
ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization
@@ -232,143 +237,164 @@ func (s *Rest) routes() chi.Router {
authHandler, avatarHandler := s.Authenticator.Handlers() authHandler, avatarHandler := s.Authenticator.Handlers()
router.Group(func(r chi.Router) { router.Route(func(r *routegroup.Bundle) {
r.Use(middleware.Timeout(5 * time.Second)) r.Use(R.Timeout(5 * time.Second))
r.Use(logInfoWithBody, rateLimiter(2), middleware.NoCache) r.Use(logInfoWithBody, rateLimiter(2), R.NoCache)
r.Use(validEmailAuth()) // reject suspicious email logins r.Use(validEmailAuth()) // reject suspicious email logins
r.Mount("/auth", authHandler) r.Handle("/auth/", authHandler)
}) })
router.Group(func(r chi.Router) { router.Route(func(r *routegroup.Bundle) {
r.Use(middleware.Timeout(5 * time.Second)) r.Use(R.Timeout(5 * time.Second))
r.Use(rateLimiter(100)) r.Use(rateLimiter(100))
r.Mount("/avatar", avatarHandler) r.Handle("/avatar/", avatarHandler)
}) })
authMiddleware := s.Authenticator.Middleware() authMiddleware := s.Authenticator.Middleware()
// api routes // api routes
router.Route("/api/v1", func(rapi chi.Router) { rapi := router.Mount("/api/v1")
rapi.Use(apiCSPMiddleware) rapi.Use(apiCSPMiddleware)
rapi.Group(func(rava chi.Router) {
rava.Use(middleware.Timeout(5 * time.Second)) rapi.Group().Route(func(rava *routegroup.Bundle) {
rava.Use(rateLimiter(100)) rava.Use(R.Timeout(5 * time.Second))
rava.Mount("/avatar", avatarHandler) rava.Use(rateLimiter(100))
rava.Handle("/avatar/", avatarHandler)
})
// open routes
rapi.Group().Route(func(ropen *routegroup.Bundle) {
ropen.Use(R.Timeout(30 * time.Second))
ropen.Use(rateLimiter(s.openRouteLimiter))
ropen.Use(authMiddleware.Trace, R.NoCache, logInfoWithBody)
ropen.HandleFunc("GET /config", s.configCtrl)
ropen.HandleFunc("GET /find", s.pubRest.findCommentsCtrl)
ropen.HandleFunc("GET /id/{id}", s.pubRest.commentByIDCtrl)
ropen.HandleFunc("GET /comments", s.pubRest.findUserCommentsCtrl)
ropen.HandleFunc("GET /last/{limit}", s.pubRest.lastCommentsCtrl)
ropen.HandleFunc("GET /count", s.pubRest.countCtrl)
ropen.HandleFunc("POST /counts", s.pubRest.countMultiCtrl)
ropen.HandleFunc("GET /list", s.pubRest.listCtrl)
ropen.HandleFunc("GET /info", s.pubRest.infoCtrl)
ropen.Mount("/rss").Route(func(rrss *routegroup.Bundle) {
rrss.HandleFunc("GET /post", s.rssRest.postCommentsCtrl)
rrss.HandleFunc("GET /site", s.rssRest.siteCommentsCtrl)
rrss.HandleFunc("GET /reply", s.rssRest.repliesCtrl)
})
})
// open routes, cached. /img lives here (not in the NoCache group above) because
// R.NoCache strips If-None-Match from incoming requests, which would
// defeat the proxy handler's 304 short-circuit. The handler sets a 30-day
// max-age on validated success responses (with a versioned etag for cache
// invalidation on revalidation); error responses get Cache-Control: no-store
// so transient failures aren't pinned in the cache.
rapi.Group().Route(func(ropen *routegroup.Bundle) {
ropen.Use(R.Timeout(30 * time.Second))
ropen.Use(rateLimiter(10))
ropen.Use(authMiddleware.Trace, logInfoWithBody)
ropen.HandleFunc("GET /img", s.ImageProxy.Handler)
ropen.HandleFunc("GET /picture/{user}/{id}", s.pubRest.loadPictureCtrl)
ropen.HandleFunc("GET /qr/telegram", s.pubRest.telegramQrCtrl)
})
// protected routes, require auth
rapi.Group().Route(func(rauth *routegroup.Bundle) {
rauth.Use(rateLimiter(10))
rauth.Use(authMiddleware.Auth, matchSiteID, R.NoCache, logInfoWithBody)
// GET /userdata streams a gzipped export of the user's data straight to the client, so it
// deliberately runs without R.Timeout: that middleware buffers the whole response in memory
// before sending and aborts at the deadline, which would hold a full export in RAM and truncate it.
rauth.HandleFunc("GET /userdata", s.privRest.userAllDataCtrl)
rauth.Group().Route(func(r *routegroup.Bundle) {
r.Use(R.Timeout(30 * time.Second))
r.HandleFunc("GET /user", s.privRest.userInfoCtrl)
})
})
// admin routes, require auth and admin users only
rapi.Mount("/admin").Route(func(radmin *routegroup.Bundle) {
radmin.Use(rateLimiter(10))
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
radmin.Use(R.NoCache, logInfoWithBody)
// bounded admin operations return small responses and get the enforcing request timeout
radmin.Group().Route(func(r *routegroup.Bundle) {
r.Use(R.Timeout(30 * time.Second))
r.HandleFunc("DELETE /comment/{id}", s.adminRest.deleteCommentCtrl)
r.HandleFunc("PUT /user/{userid}", s.adminRest.setBlockCtrl)
r.HandleFunc("DELETE /user/{userid}", s.adminRest.deleteUserCtrl)
r.HandleFunc("GET /user/{userid}", s.adminRest.getUserInfoCtrl)
r.With(rejectHead("GET")).HandleFunc("GET /deleteme", s.adminRest.deleteMeRequestCtrl)
r.HandleFunc("PUT /verify/{userid}", s.adminRest.setVerifyCtrl)
r.HandleFunc("PUT /pin/{id}", s.adminRest.setPinCtrl)
r.HandleFunc("GET /blocked", s.adminRest.blockedUsersCtrl)
r.HandleFunc("PUT /readonly", s.adminRest.setReadOnlyCtrl)
r.HandleFunc("PUT /title/{id}", s.adminRest.setTitleCtrl)
}) })
// open routes // migrator routes deliberately run without R.Timeout: GET /export streams a full-site
rapi.Group(func(ropen chi.Router) { // backup, GET /wait long-polls for up to 15m, and import/remap ingest large uploads. The
ropen.Use(middleware.Timeout(30 * time.Second)) // enforcing timeout buffers the whole response and aborts at the deadline, which would
ropen.Use(rateLimiter(s.openRouteLimiter)) // truncate backups, break waiting, and reject large imports.
ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody) radmin.HandleFunc("GET /export", s.adminRest.migrator.exportCtrl)
ropen.Get("/config", s.configCtrl) radmin.HandleFunc("POST /import", s.adminRest.migrator.importCtrl)
ropen.Get("/find", s.pubRest.findCommentsCtrl) radmin.HandleFunc("POST /import/form", s.adminRest.migrator.importFormCtrl)
ropen.Get("/id/{id}", s.pubRest.commentByIDCtrl) radmin.HandleFunc("POST /remap", s.adminRest.migrator.remapCtrl)
ropen.Get("/comments", s.pubRest.findUserCommentsCtrl) radmin.HandleFunc("GET /wait", s.adminRest.migrator.waitCtrl)
ropen.Get("/last/{limit}", s.pubRest.lastCommentsCtrl) })
ropen.Get("/count", s.pubRest.countCtrl)
ropen.Post("/counts", s.pubRest.countMultiCtrl)
ropen.Get("/list", s.pubRest.listCtrl)
ropen.Get("/info", s.pubRest.infoCtrl)
ropen.Route("/rss", func(rrss chi.Router) { // protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
rrss.Get("/post", s.rssRest.postCommentsCtrl) rapi.Group().Route(func(rauth *routegroup.Bundle) {
rrss.Get("/site", s.rssRest.siteCommentsCtrl) rauth.Use(R.Timeout(10 * time.Second))
rrss.Get("/reply", s.rssRest.repliesCtrl) rauth.Use(rateLimiter(s.updateLimiter()))
}) rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
}) rauth.Use(R.NoCache, logInfoWithBody)
// open routes, cached. /img lives here (not in the NoCache group above) because rauth.HandleFunc("PUT /comment/{id}", s.privRest.updateCommentCtrl)
// middleware.NoCache strips If-None-Match from incoming requests, which would rauth.HandleFunc("POST /preview", s.privRest.previewCommentCtrl)
// defeat the proxy handler's 304 short-circuit. The handler sets a 30-day rauth.HandleFunc("POST /comment", s.privRest.createCommentCtrl)
// max-age on validated success responses (with a versioned etag for cache rauth.HandleFunc("PUT /vote/{id}", s.privRest.voteCtrl)
// invalidation on revalidation); error responses get Cache-Control: no-store rauth.With(rejectAnonUser).HandleFunc("POST /deleteme", s.privRest.deleteMeCtrl)
// so transient failures aren't pinned in the cache. rauth.With(rejectAnonUser).HandleFunc("GET /email", s.privRest.getEmailCtrl)
rapi.Group(func(ropen chi.Router) { rauth.With(rejectAnonUser).HandleFunc("POST /email/subscribe", s.privRest.sendEmailConfirmationCtrl)
ropen.Use(middleware.Timeout(30 * time.Second)) rauth.With(rejectAnonUser).HandleFunc("POST /email/confirm", s.privRest.setConfirmedEmailCtrl)
ropen.Use(rateLimiter(10)) rauth.With(rejectAnonUser).HandleFunc("DELETE /email", s.privRest.deleteEmailCtrl)
ropen.Use(authMiddleware.Trace, logInfoWithBody) rauth.With(rejectAnonUser, rejectHead("GET")).HandleFunc("GET /telegram/subscribe", s.privRest.telegramSubscribeCtrl)
ropen.Get("/img", s.ImageProxy.Handler) rauth.With(rejectAnonUser).HandleFunc("DELETE /telegram", s.privRest.deleteTelegramCtrl)
ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl) })
ropen.Get("/qr/telegram", s.pubRest.telegramQrCtrl)
})
// protected routes, require auth // protected routes, anonymous rejected
rapi.Group(func(rauth chi.Router) { rapi.Group().Route(func(rauth *routegroup.Bundle) {
rauth.Use(middleware.Timeout(30 * time.Second)) rauth.Use(R.Timeout(10 * time.Second))
rauth.Use(rateLimiter(10)) rauth.Use(rateLimiter(s.updateLimiter()))
rauth.Use(authMiddleware.Auth, matchSiteID, middleware.NoCache, logInfoWithBody) rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
rauth.Get("/user", s.privRest.userInfoCtrl) rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
rauth.Get("/userdata", s.privRest.userAllDataCtrl) rauth.HandleFunc("POST /picture", s.privRest.savePictureCtrl)
})
// admin routes, require auth and admin users only
rapi.Route("/admin", func(radmin chi.Router) {
radmin.Use(middleware.Timeout(30 * time.Second))
radmin.Use(rateLimiter(10))
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
radmin.Use(middleware.NoCache, logInfoWithBody)
radmin.Delete("/comment/{id}", s.adminRest.deleteCommentCtrl)
radmin.Put("/user/{userid}", s.adminRest.setBlockCtrl)
radmin.Delete("/user/{userid}", s.adminRest.deleteUserCtrl)
radmin.Get("/user/{userid}", s.adminRest.getUserInfoCtrl)
radmin.Get("/deleteme", s.adminRest.deleteMeRequestCtrl)
radmin.Put("/verify/{userid}", s.adminRest.setVerifyCtrl)
radmin.Put("/pin/{id}", s.adminRest.setPinCtrl)
radmin.Get("/blocked", s.adminRest.blockedUsersCtrl)
radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl)
radmin.Put("/title/{id}", s.adminRest.setTitleCtrl)
// migrator
radmin.Get("/export", s.adminRest.migrator.exportCtrl)
radmin.Post("/import", s.adminRest.migrator.importCtrl)
radmin.Post("/import/form", s.adminRest.migrator.importFormCtrl)
radmin.Post("/remap", s.adminRest.migrator.remapCtrl)
radmin.Get("/wait", s.adminRest.migrator.waitCtrl)
})
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(10 * time.Second))
rauth.Use(rateLimiter(s.updateLimiter()))
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
rauth.Use(middleware.NoCache, logInfoWithBody)
rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl)
rauth.Post("/preview", s.privRest.previewCommentCtrl)
rauth.Post("/comment", s.privRest.createCommentCtrl)
rauth.Put("/vote/{id}", s.privRest.voteCtrl)
rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl)
rauth.With(rejectAnonUser).Get("/email", s.privRest.getEmailCtrl)
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
rauth.With(rejectAnonUser).Get("/telegram/subscribe", s.privRest.telegramSubscribeCtrl)
rauth.With(rejectAnonUser).Delete("/telegram", s.privRest.deleteTelegramCtrl)
})
// protected routes, anonymous rejected
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(10 * time.Second))
rauth.Use(rateLimiter(s.updateLimiter()))
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
rauth.Post("/picture", s.privRest.savePictureCtrl)
})
}) })
// open routes on root level // open routes on root level
router.Group(func(rroot chi.Router) { router.Route(func(rroot *routegroup.Bundle) {
rroot.Use(middleware.Timeout(10 * time.Second)) rroot.Use(R.Timeout(10 * time.Second))
rroot.Use(rateLimiter(50)) rroot.Use(rateLimiter(50))
rroot.Get("/robots.txt", s.pubRest.robotsCtrl) rroot.HandleFunc("GET /robots.txt", s.pubRest.robotsCtrl)
rroot.Get("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl) rroot.With(rejectHead("GET, POST")).HandleFunc("GET /email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
rroot.Post("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl) rroot.HandleFunc("POST /email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
}) })
// file server for static content from s.WebRoot on path /web // file server for /web: the frontend build first, then the assets embedded in the binary.
addFileServer(router, s.WebFS, s.WebRoot, s.Version) // the build is embedded under web/ by app/cmd, so that prefix is stripped here. fs.Sub only
// fails for an fs.SubFS that refuses, and a nil result would panic on the first request, so
// serve nothing from the frontend rather than serving it at the wrong paths
embeddedFrontend, err := fs.Sub(s.WebFS, "web")
if err != nil {
log.Printf("[WARN] no embedded frontend, serving built-in assets only: %v", err)
embeddedFrontend = emptyFS{}
}
addFileServer(router, embeddedFrontend, s.WebRoot, s.Version, s.RemarkURL)
return router return router
} }
@@ -481,26 +507,36 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
R.RenderJSON(w, cnf) R.RenderJSON(w, cnf)
} }
// serves static files from the webRoot directory or files embedded into the compiled binary if that directory is absent // serves /web from the frontend build, falling back to the assets embedded in the binary for
func addFileServer(r chi.Router, embedFS embed.FS, webRoot, version string) { // names the build does not produce. the frontend build is read from webRoot on disk, or from the
var webFS http.Handler // copy embedded at app/cmd/web when that directory is absent.
func addFileServer(r *routegroup.Bundle, embeddedFrontend fs.FS, webRoot, version, remarkURL string) {
frontendFS := embeddedFrontend
if _, err := os.Stat(webRoot); err == nil { if _, err := os.Stat(webRoot); err == nil {
log.Printf("[INFO] run file server from %s from the disk", webRoot) log.Printf("[INFO] run file server from %s from the disk", webRoot)
webFS = http.FileServer(http.Dir(webRoot)) frontendFS = os.DirFS(webRoot)
} else { } else {
log.Printf("[INFO] run file server, embedded") log.Printf("[INFO] run file server, embedded")
var contentFS, _ = fs.Sub(embedFS, "web")
webFS = http.FileServer(http.FS(contentFS))
} }
webFS = http.StripPrefix("/web", webFS) // wrapped rather than substituted once at startup: the disk root can change under a running
r.Get("/web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP) // server, and the docker image has already substituted its copy, where this is a no-op
sources := templatedFS{
fs: webFiles{frontend: frontendFS, embedded: webassets.FS},
remarkURL: remarkURL,
}
webFS := http.StripPrefix("/web", http.FileServer(http.FS(sources)))
r.HandleFunc("GET /web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP)
r.With(rateLimiter(20), r.With(rateLimiter(20),
middleware.Timeout(10*time.Second), R.Timeout(10*time.Second),
cacheControl(time.Hour, version), // the served body now depends on remarkURL, so it has to be part of the validator. Without
).Get("/web/*", func(w http.ResponseWriter, r *http.Request) { // it an operator who corrects a wrong REMARK_URL and restarts the same binary keeps getting
// 304 on revalidation, and the client keeps a bundle addressed to the old host for good,
// since no-cache means it revalidates rather than aging out
cacheControl(time.Hour, version+":"+remarkURL),
).HandleFunc("GET /web/", func(w http.ResponseWriter, r *http.Request) {
// don't show dirs, just serve files // don't show dirs, just serve files
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != ("/web/") { if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != ("/web/") {
http.NotFound(w, r) http.NotFound(w, r)
@@ -556,192 +592,6 @@ func URLKeyWithUser(r *http.Request) string {
return key return key
} }
// rejectAnonUser is a middleware rejecting anonymous users
func rejectAnonUser(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if strings.HasPrefix(user.ID, "anonymous_") {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
func matchSiteID(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// skip for basic auth user
if user.Name == "admin" && user.ID == "admin" {
next.ServeHTTP(w, r)
return
}
siteID := r.URL.Query().Get("site")
// require an explicit site so the user.SiteID check below cannot be bypassed
// by simply omitting the query parameter
if siteID == "" || user.SiteID != siteID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// cacheControl is a middleware setting cache expiration. Using url+version as etag
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
etag := func(r *http.Request, version string) string {
s := version + ":" + r.URL.String()
return store.EncodeID(s)
}
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
e := `"` + etag(r, version) + `"`
w.Header().Set("Etag", e)
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, e) {
w.WriteHeader(http.StatusNotModified)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes
// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps
// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages
// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or
// images — none of those should ever execute scripts when rendered, so they get the
// strictest policy available as defense-in-depth against future trust-boundary bugs.
//
// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same
// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition:
// inline; filename="image" (framing the response as a file rather than a renderable
// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and-
// braces: if a future route refactor bypasses this middleware, the image handlers still
// emit the policy.
func apiCSPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", rest.StrictImageCSP)
next.ServeHTTP(w, r)
})
}
// securityHeadersMiddleware sets security-related headers:
// - Content-Security-Policy: controls which resources the browser is allowed to load
// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget
// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type,
// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS
// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin
// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains
// and nothing at all on HTTPS→HTTP downgrades
func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
imgSrc := "*"
if imageProxyEnabled {
imgSrc = "'self'"
}
frameAncestors := "*"
if len(allowedAncestors) > 0 {
frameAncestors = strings.Join(allowedAncestors, " ")
}
// font-src is set to 'none' (no @font-face / no base64 fonts in the bundle).
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
}
// subscribersOnly is a middleware rejecting non-paid_sub users
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if enable {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !user.PaidSub {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// validEmailAuth is a middleware for auth endpoints for email method.
// it rejects login request if user, site or email are suspicious
func validEmailAuth() func(http.Handler) http.Handler {
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/email/login" {
// not email login, skip the check
h.ServeHTTP(w, r)
return
}
if u := r.URL.Query().Get("user"); u != "" {
if !reUser.MatchString(u) {
log.Printf("[WARN] suspicious user rejected: %s", u)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
if a := r.URL.Query().Get("address"); a != "" {
if _, err := mail.ParseAddress(a); err != nil {
log.Printf("[WARN] suspicious address rejected: %s", a)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
if s := r.URL.Query().Get("site"); s != "" {
if !reSite.MatchString(s) {
log.Printf("[WARN] suspicious site rejected: %s", s)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
func parseError(err error, defaultCode int) (code int) { func parseError(err error, defaultCode int) (code int) {
code = defaultCode code = defaultCode
@@ -765,16 +615,3 @@ func parseError(err error, defaultCode int) (code int) {
return code return code
} }
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
// tollbooth v8 requires explicit IP lookup method to be set.
// uses RemoteAddr which is set by chi's middleware.RealIP to the real client IP
// from X-Forwarded-For, X-Real-IP, or True-Client-IP headers.
func rateLimiter(maxReq float64) func(http.Handler) http.Handler {
lmt := tollbooth.NewLimiter(maxReq, nil)
lmt.SetIPLookup(limiter.IPLookup{
Name: "RemoteAddr",
IndexFromRight: 0,
})
return tollbooth.HTTPMiddleware(lmt)
}
+11 -14
View File
@@ -15,14 +15,12 @@ import (
"strings" "strings"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/go-pkgz/auth/v2" "github.com/go-pkgz/auth/v2"
"github.com/go-pkgz/auth/v2/token" "github.com/go-pkgz/auth/v2/token"
cache "github.com/go-pkgz/lcw/v2" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/hashicorp/go-multierror"
"github.com/umputun/remark42/backend/app/notify" "github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/rest" "github.com/umputun/remark42/backend/app/rest"
@@ -193,7 +191,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r) user := rest.MustGetUserInfo(r)
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
id := chi.URLParam(r, "id") id := r.PathValue("id")
log.Printf("[DEBUG] update comment %s", id) log.Printf("[DEBUG] update comment %s", id)
@@ -260,7 +258,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
return return
} }
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
id := chi.URLParam(r, "id") id := r.PathValue("id")
log.Printf("[DEBUG] vote for comment %s", id) log.Printf("[DEBUG] vote for comment %s", id)
vote := r.URL.Query().Get("vote") == "1" vote := r.URL.Query().Get("vote") == "1"
@@ -664,10 +662,8 @@ func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
return e return e
} }
var merr error // send user prefix, user info and comments prefix
merr = multierror.Append(merr, write([]byte(`{"info": `))) // send user prefix errs := []error{write([]byte(`{"info": `)), write(userB), write([]byte(`, "comments":`))}
merr = multierror.Append(merr, write(userB)) // send user info
merr = multierror.Append(merr, write([]byte(`, "comments":`))) // send comments prefix
// get comments in 100 in each paginated request // get comments in 100 in each paginated request
for i := range 100 { for i := range 100 {
@@ -682,15 +678,15 @@ func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
return return
} }
merr = multierror.Append(merr, write(b)) errs = append(errs, write(b))
if len(comments) != 100 { if len(comments) != 100 {
break break
} }
} }
merr = multierror.Append(merr, write([]byte(`}`))) errs = append(errs, write([]byte(`}`)))
if merr.(*multierror.Error).ErrorOrNil() != nil { if err := errors.Join(errs...); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, merr, "can't write user info", rest.ErrInternal) rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't write user info", rest.ErrInternal)
return return
} }
} }
@@ -709,8 +705,9 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)), NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
}, },
User: &token.User{ User: &token.User{
ID: user.ID, ID: user.ID,
Name: user.Name, Name: user.Name,
Picture: user.Picture, // carried so the avatar can be removed when the request is processed
Attributes: map[string]any{ Attributes: map[string]any{
"delete_me": true, // prevents this token from being used for login "delete_me": true, // prevents this token from being used for login
}, },
+31 -44
View File
@@ -144,7 +144,7 @@ func TestRest_CreateAndPreviewWithImage(t *testing.T) {
assert.Equal(t, false, pngRead, "original image is not yet accessed by server") assert.Equal(t, false, pngRead, "original image is not yet accessed by server")
// retrieve the image from the cache // retrieve the image from the cache
imgURL := strings.Split(strings.Split(string(b), "src=\"")[1], "\"")[0] imgURL, _, _ := strings.Cut(strings.Split(string(b), "src=\"")[1], "\"")
// replace srv.RemarkURL with ts.URL // replace srv.RemarkURL with ts.URL
imgURL = strings.ReplaceAll(imgURL, srv.RemarkURL, ts.URL) imgURL = strings.ReplaceAll(imgURL, srv.RemarkURL, ts.URL)
resp, err = http.Get(imgURL) resp, err = http.Get(imgURL)
@@ -432,6 +432,7 @@ func TestRest_Update(t *testing.T) {
strings.NewReader(`{"text":"updated text", "summary":"my edit"}`)) strings.NewReader(`{"text":"updated text", "summary":"my edit"}`))
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Add("X-JWT", devToken) req.Header.Add("X-JWT", devToken)
beforeUpdate := time.Now()
b, err := client.Do(req) b, err := client.Do(req)
assert.NoError(t, err) assert.NoError(t, err)
body, err := io.ReadAll(b.Body) body, err := io.ReadAll(b.Body)
@@ -447,7 +448,7 @@ func TestRest_Update(t *testing.T) {
assert.Equal(t, "<p>updated text</p>\n", c2.Text) assert.Equal(t, "<p>updated text</p>\n", c2.Text)
assert.Equal(t, "updated text", c2.Orig) assert.Equal(t, "updated text", c2.Orig)
assert.Equal(t, "my edit", c2.Edit.Summary) assert.Equal(t, "my edit", c2.Edit.Summary)
assert.True(t, time.Since(c2.Edit.Timestamp) < 1*time.Second) assert.WithinRange(t, c2.Edit.Timestamp, beforeUpdate, time.Now(), "edit stamped during the update")
// read updated comment // read updated comment
res, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id)) res, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
@@ -596,7 +597,7 @@ func TestRest_DeleteChildThenParent(t *testing.T) {
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, idC2), http.NoBody) fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, idC2), http.NoBody)
require.NoError(t, err) require.NoError(t, err)
requireAdminOnly(t, req) requireAdminOnly(t, req)
resp, err = sendReq(t, req, adminUmputunToken) resp, err = sendReq(req, adminUmputunToken)
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -784,7 +785,7 @@ func TestRest_Vote(t *testing.T) {
req, err := http.NewRequest("GET", req, err := http.NewRequest("GET",
fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody) fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
resp, err := sendReq(t, req, adminUmputunToken) resp, err := sendReq(req, adminUmputunToken)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
cr = store.Comment{} cr = store.Comment{}
@@ -912,6 +913,9 @@ func TestRest_EmailAndTelegram(t *testing.T) {
{description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK}, {description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
{description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK}, {description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK}, {description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
// telegramSubscribeCtrl mutates state, so HEAD (which stdlib ServeMux would route to the
// GET handler) must be rejected by rejectHead before it runs
{description: "HEAD is rejected on telegram subscribe", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodHead, responseCode: http.StatusMethodNotAllowed},
} }
client := http.Client{} client := http.Client{}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
@@ -971,9 +975,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
parentComment := store.Comment{} parentComment := store.Comment{}
require.NoError(t, json.Unmarshal(body, &parentComment)) require.NoError(t, json.Unmarshal(body, &parentComment))
// wait for mock notification Submit to kick off waitForCount(t, 1, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 1, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[0].Emails) assert.Empty(t, mockDestination.Get()[0].Emails)
// create child comment from another user, email notification only to admin expected // create child comment from another user, email notification only to admin expected
@@ -991,9 +993,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCount(t, 2, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[1].Emails) assert.Empty(t, mockDestination.Get()[1].Emails)
// send confirmation token for email // send confirmation token for email
@@ -1010,9 +1010,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCount(t, 1, func() int { return len(mockDestination.GetVerify()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 1, len(mockDestination.GetVerify()))
assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email) assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email)
verificationToken := mockDestination.GetVerify()[0].Token verificationToken := mockDestination.GetVerify()[0].Token
@@ -1084,9 +1082,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCount(t, 3, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 3, len(mockDestination.Get()))
assert.Equal(t, []string{"good@example.com"}, mockDestination.Get()[2].Emails) assert.Equal(t, []string{"good@example.com"}, mockDestination.Get()[2].Emails)
// delete user's email // delete user's email
@@ -1114,9 +1110,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCountSettled(t, 4, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 4, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[3].Emails) assert.Empty(t, mockDestination.Get()[3].Emails)
// confirm email via subscribe call with query params, old behavior, email notification is expected // confirm email via subscribe call with query params, old behavior, email notification is expected
@@ -1133,9 +1127,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCount(t, 2, func() int { return len(mockDestination.GetVerify()) }, "verification email was sent")
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.GetVerify()), "verification email was sent")
// get email user information to verify there is no subscription yet // get email user information to verify there is no subscription yet
req, err = http.NewRequest( req, err = http.NewRequest(
@@ -1170,9 +1162,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCountSettled(t, 2, func() int { return len(mockDestination.GetVerify()) }, "no new verification email was sent")
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.GetVerify()), "no new verification email was sent")
// get email user information to verify the subscription happened without the confirmation call // get email user information to verify the subscription happened without the confirmation call
req, err = http.NewRequest( req, err = http.NewRequest(
@@ -1221,9 +1211,7 @@ func TestRest_TelegramNotification(t *testing.T) {
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
parentComment := store.Comment{} parentComment := store.Comment{}
require.NoError(t, json.Unmarshal(body, &parentComment)) require.NoError(t, json.Unmarshal(body, &parentComment))
// wait for mock notification Submit to kick off waitForCount(t, 1, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 1, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[0].Telegrams) assert.Empty(t, mockDestination.Get()[0].Telegrams)
// create child comment from another user, telegram notification only to admin expected // create child comment from another user, telegram notification only to admin expected
@@ -1241,9 +1229,7 @@ func TestRest_TelegramNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCount(t, 2, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[1].Telegrams) assert.Empty(t, mockDestination.Get()[1].Telegrams)
// subscribe to telegram while the telegram destination is absent // subscribe to telegram while the telegram destination is absent
@@ -1354,9 +1340,7 @@ func TestRest_TelegramNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCount(t, 3, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 3, len(mockDestination.Get()))
assert.Equal(t, []string{"good_telegram"}, mockDestination.Get()[2].Telegrams) assert.Equal(t, []string{"good_telegram"}, mockDestination.Get()[2].Telegrams)
// delete user's telegram // delete user's telegram
@@ -1384,9 +1368,7 @@ func TestRest_TelegramNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off waitForCountSettled(t, 4, func() int { return len(mockDestination.Get()) })
time.Sleep(time.Millisecond * 30)
require.Equal(t, 4, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[3].Telegrams) assert.Empty(t, mockDestination.Get()[3].Telegrams)
} }
@@ -1409,7 +1391,7 @@ func TestRest_UserAllData(t *testing.T) {
_, err = srv.DataService.Create(c3) _, err = srv.DataService.Create(c3)
require.NoError(t, err) require.NoError(t, err)
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody) req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
require.NoError(t, err) require.NoError(t, err)
@@ -1462,7 +1444,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
_, err := srv.DataService.Create(c) _, err := srv.DataService.Create(c)
require.NoError(t, err) require.NoError(t, err)
} }
client := &http.Client{Timeout: 1 * time.Second} client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody) req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
require.NoError(t, err) require.NoError(t, err)
@@ -1509,6 +1491,8 @@ func TestRest_DeleteMe(t *testing.T) {
claims, err := srv.Authenticator.TokenService().Parse(tkn) claims, err := srv.Authenticator.TokenService().Parse(tkn)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "provider1_dev", claims.User.ID) assert.Equal(t, "provider1_dev", claims.User.ID)
assert.Equal(t, "http://example.com/pic.png", claims.User.Picture,
"delete_me token must carry the user's picture so the avatar can be removed when the request is processed")
assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+tkn, m["link"]) assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+tkn, m["link"])
req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody) req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody)
@@ -1603,7 +1587,9 @@ func TestRest_CreateWithPictures(t *testing.T) {
Staging: "/tmp/remark42/images.staging", Staging: "/tmp/remark42/images.staging",
Location: "/tmp/remark42/images", Location: "/tmp/remark42/images",
}, image.ServiceParams{ }, image.ServiceParams{
EditDuration: 100 * time.Millisecond, // the "not moved yet" checks below run right after the comment POST returns, so the
// commit window has to be wide enough that a stalled runner cannot close it first
EditDuration: 3 * time.Second,
MaxSize: 2000, MaxSize: 2000,
ImageAPI: svc.RemarkURL + "/api/v1/picture/", ImageAPI: svc.RemarkURL + "/api/v1/picture/",
ProxyAPI: svc.RemarkURL + "/api/v1/img", ProxyAPI: svc.RemarkURL + "/api/v1/img",
@@ -1666,11 +1652,12 @@ func TestRest_CreateWithPictures(t *testing.T) {
assert.Error(t, err, "picture %d not moved from staging yet", i) assert.Error(t, err, "picture %d not moved from staging yet", i)
} }
time.Sleep(1500 * time.Millisecond) // the commit runs once EditDuration expires
for i := range ids { for i := range ids {
_, err = os.Stat("/tmp/remark42/images/" + ids[i]) require.Eventually(t, func() bool {
assert.NoError(t, err, "picture %d moved from staging and available in permanent location", i) _, e := os.Stat("/tmp/remark42/images/" + ids[i])
return e == nil
}, waitTimeout, pollInterval, "picture %d moved from staging and available in permanent location", i)
} }
} }
+3 -4
View File
@@ -13,7 +13,6 @@ import (
"time" "time"
"unicode" "unicode"
"github.com/go-chi/chi/v5"
cache "github.com/go-pkgz/lcw/v2" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
@@ -188,7 +187,7 @@ func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site") siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] get last comments for %s", siteID) log.Printf("[DEBUG] get last comments for %s", siteID)
limit, err := strconv.Atoi(chi.URLParam(r, "limit")) limit, err := strconv.Atoi(r.PathValue("limit"))
if err != nil { if err != nil {
limit = 0 limit = 0
} }
@@ -222,7 +221,7 @@ func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
// GET /id/{id}?site=siteID&url=post-url - gets a comment by id // GET /id/{id}?site=siteID&url=post-url - gets a comment by id
func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := r.PathValue("id")
siteID := r.URL.Query().Get("site") siteID := r.URL.Query().Get("site")
url := r.URL.Query().Get("url") url := r.URL.Query().Get("url")
@@ -402,7 +401,7 @@ func sendPictureError(w http.ResponseWriter, r *http.Request, status int, err er
func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) { func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
rest.SetImageDefenseHeaders(w) rest.SetImageDefenseHeaders(w)
user, imgID := chi.URLParam(r, "user"), chi.URLParam(r, "id") user, imgID := r.PathValue("user"), r.PathValue("id")
if user == "" || imgID == "" || !safePictureSegment(user) || !safePictureSegment(imgID) { if user == "" || imgID == "" || !safePictureSegment(user) || !safePictureSegment(imgID) {
log.Printf("[WARN] rejected picture request with unsafe id segments user=%q id=%q", user, imgID) log.Printf("[WARN] rejected picture request with unsafe id segments user=%q id=%q", user, imgID)
sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("invalid picture id"), "invalid picture id", rest.ErrAssetNotFound) sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("invalid picture id"), "invalid picture id", rest.ErrAssetNotFound)
+66 -33
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image/png"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -14,9 +15,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-chi/chi/v5"
cache "github.com/go-pkgz/lcw/v2" cache "github.com/go-pkgz/lcw/v2"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/go-pkgz/routegroup"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -381,11 +382,12 @@ func TestRest_Last(t *testing.T) {
c2 := store.Comment{Text: "test test #2", ParentID: "p1", c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}} Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
// add 3 comments // add 3 comments, with the clock pushed past a millisecond boundary in between so the two
// "since" values below are distinct
ts1 := time.Now().UnixNano() / 1000000 ts1 := time.Now().UnixNano() / 1000000
addComment(t, c1, ts) addComment(t, c1, ts)
id1 := addComment(t, c1, ts) id1 := addComment(t, c1, ts)
time.Sleep(10 * time.Millisecond) waitPastMillisecond(time.Now())
ts2 := time.Now().UnixNano() / 1000000 ts2 := time.Now().UnixNano() / 1000000
id2 := addComment(t, c2, ts) id2 := addComment(t, c2, ts)
@@ -539,9 +541,17 @@ func TestRest_FindUserComments_CWE_918(t *testing.T) {
assert.Equal(t, arbitraryServer.URL, resp.Comments[0].Locator.URL, "arbitrary URL provided by the request") assert.Equal(t, arbitraryServer.URL, resp.Comments[0].Locator.URL, "arbitrary URL provided by the request")
} }
// waitPastMillisecond blocks until the wall clock moves past ts's millisecond, so whatever is
// created next gets a distinct value for the millisecond-precision "since" filter
func waitPastMillisecond(ts time.Time) {
next := ts.Truncate(time.Millisecond).Add(time.Millisecond)
time.Sleep(time.Until(next) + time.Microsecond) // a non-positive duration returns at once
}
func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) { func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
// test that comment counting is consistent between tree and plain formats // test that comment counting is consistent between tree and plain formats
ts, srv, teardown := startupT(t) // the open-route limit is lifted so the subtests below can run back to back
ts, srv, teardown := startupT(t, func(srv *Rest) { srv.openRouteLimiter = 100000 })
defer teardown() defer teardown()
commentLocator := store.Locator{URL: "test-url", SiteID: "remark42"} commentLocator := store.Locator{URL: "test-url", SiteID: "remark42"}
@@ -567,55 +577,55 @@ func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
} }
// adding initial comments (8 to test-url and 1 to another-url) and voting, and delete two of comments to the first post. // adding initial comments (8 to test-url and 1 to another-url) and voting, and delete two of comments to the first post.
// with sleep so that at least few millisecond pass between each comment // each comment waits for the clock to pass the previous one's millisecond so the "since"
// and later we would be able to use that in "since" filter with millisecond precision // filter, which has millisecond precision, can tell them apart
ids := make([]string, 9) ids := make([]string, 9)
timestamps := make([]time.Time, 9) timestamps := make([]time.Time, 9)
c1 := store.Comment{Text: "top-level comment 1", Locator: commentLocator} c1 := store.Comment{Text: "top-level comment 1", Locator: commentLocator}
ids[0], timestamps[0] = addCommentGetCreatedTime(t, c1, ts) ids[0], timestamps[0] = addCommentGetCreatedTime(t, c1, ts)
// #3 by score // #3 by score
setScore(commentLocator, ids[0], 1) setScore(commentLocator, ids[0], 1)
time.Sleep(time.Millisecond * 5) waitPastMillisecond(timestamps[0])
c2 := store.Comment{Text: "top-level comment 2", Locator: commentLocator} c2 := store.Comment{Text: "top-level comment 2", Locator: commentLocator}
ids[1], timestamps[1] = addCommentGetCreatedTime(t, c2, ts) ids[1], timestamps[1] = addCommentGetCreatedTime(t, c2, ts)
// #2 by score // #2 by score
setScore(commentLocator, ids[1], 2) setScore(commentLocator, ids[1], 2)
time.Sleep(time.Millisecond * 5) waitPastMillisecond(timestamps[1])
c3 := store.Comment{Text: "second-level comment 1", ParentID: ids[0], Locator: commentLocator} c3 := store.Comment{Text: "second-level comment 1", ParentID: ids[0], Locator: commentLocator}
ids[2], timestamps[2] = addCommentGetCreatedTime(t, c3, ts) ids[2], timestamps[2] = addCommentGetCreatedTime(t, c3, ts)
// #1 by score // #1 by score
setScore(commentLocator, ids[2], 10) setScore(commentLocator, ids[2], 10)
time.Sleep(time.Millisecond * 5) waitPastMillisecond(timestamps[2])
c4 := store.Comment{Text: "third-level comment 1", ParentID: ids[2], Locator: commentLocator} c4 := store.Comment{Text: "third-level comment 1", ParentID: ids[2], Locator: commentLocator}
ids[3], timestamps[3] = addCommentGetCreatedTime(t, c4, ts) ids[3], timestamps[3] = addCommentGetCreatedTime(t, c4, ts)
// #5 by score, #1 by controversy // #5 by score, #1 by controversy
setScore(commentLocator, ids[3], 4) setScore(commentLocator, ids[3], 4)
setScore(commentLocator, ids[3], -4) setScore(commentLocator, ids[3], -4)
time.Sleep(time.Millisecond * 5) waitPastMillisecond(timestamps[3])
c5 := store.Comment{Text: "second-level comment 2", ParentID: ids[1], Locator: commentLocator} c5 := store.Comment{Text: "second-level comment 2", ParentID: ids[1], Locator: commentLocator}
ids[4], timestamps[4] = addCommentGetCreatedTime(t, c5, ts) ids[4], timestamps[4] = addCommentGetCreatedTime(t, c5, ts)
// #5 by score, #2 by controversy // #5 by score, #2 by controversy
setScore(commentLocator, ids[4], 2) setScore(commentLocator, ids[4], 2)
setScore(commentLocator, ids[4], -3) setScore(commentLocator, ids[4], -3)
time.Sleep(time.Millisecond * 5) waitPastMillisecond(timestamps[4])
c6 := store.Comment{Text: "deleted third-level comment 2", ParentID: ids[4], Locator: commentLocator} c6 := store.Comment{Text: "deleted third-level comment 2", ParentID: ids[4], Locator: commentLocator}
ids[5], timestamps[5] = addCommentGetCreatedTime(t, c6, ts) ids[5], timestamps[5] = addCommentGetCreatedTime(t, c6, ts)
// deleted later so not visible in site-wide requests // deleted later so not visible in site-wide requests
setScore(commentLocator, ids[5], 10) setScore(commentLocator, ids[5], 10)
setScore(commentLocator, ids[5], -10) setScore(commentLocator, ids[5], -10)
time.Sleep(time.Millisecond * 5) waitPastMillisecond(timestamps[5])
c7 := store.Comment{Text: "top-level comment 3", Locator: commentLocator} c7 := store.Comment{Text: "top-level comment 3", Locator: commentLocator}
ids[6], timestamps[6] = addCommentGetCreatedTime(t, c7, ts) ids[6], timestamps[6] = addCommentGetCreatedTime(t, c7, ts)
// #6 by score, #4 by controversy // #6 by score, #4 by controversy
setScore(commentLocator, ids[6], -3) setScore(commentLocator, ids[6], -3)
setScore(commentLocator, ids[6], 1) setScore(commentLocator, ids[6], 1)
time.Sleep(time.Millisecond * 5) waitPastMillisecond(timestamps[6])
c8 := store.Comment{Text: "deleted second-level comment 3", ParentID: ids[6], Locator: commentLocator} c8 := store.Comment{Text: "deleted second-level comment 3", ParentID: ids[6], Locator: commentLocator}
ids[7], timestamps[7] = addCommentGetCreatedTime(t, c8, ts) ids[7], timestamps[7] = addCommentGetCreatedTime(t, c8, ts)
@@ -733,16 +743,16 @@ func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
{"format=tree&limit=bad", `{"code":1,"details":"bad limit value","error":"strconv.Atoi: parsing \"bad\": invalid syntax"}`}, {"format=tree&limit=bad", `{"code":1,"details":"bad limit value","error":"strconv.Atoi: parsing \"bad\": invalid syntax"}`},
{"format=tree&offset_id=bad", `{"code":1,"details":"bad offset_id value","error":"invalid UUID length: 3"}`}, {"format=tree&offset_id=bad", `{"code":1,"details":"bad offset_id value","error":"invalid UUID length: 3"}`},
{"format=tree&limit=2", `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]}, {"format=tree&limit=2", `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
{"format=tree&limit=6", `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]}, {"format=tree&limit=6", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
{"format=tree&limit=7", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]}, {"format=tree&limit=7", `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
{"format=tree&url=test-url&limit=2", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]}, {"format=tree&url=test-url&limit=2", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
{"format=tree&url=test-url&limit=6", `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]}, {"format=tree&url=test-url&limit=6", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
{"format=tree&url=test-url&limit=7", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]}, {"format=tree&url=test-url&limit=7", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
// start after first top-level comment // start after first top-level comment
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[0]), `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]}, {fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[0]), `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]},
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[0]), `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]}, {fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[0]), `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]},
// start after second top-level comment // start after second top-level comment
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[1]), `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]}, {fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[1]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[1]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]}, {fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[1]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
// start after third top-level comment, so expect comment to post 2, or no comments on post 1 if "url" is set // start after third top-level comment, so expect comment to post 2, or no comments on post 1 if "url" is set
{fmt.Sprintf("format=tree&limit=1&offset_id=%s", ids[6]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]}, {fmt.Sprintf("format=tree&limit=1&offset_id=%s", ids[6]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
@@ -771,15 +781,17 @@ func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
t.Run(tc.params, func(t *testing.T) { t.Run(tc.params, func(t *testing.T) {
url := fmt.Sprintf(ts.URL+"/api/v1/find?site=remark42&%s", tc.params) url := fmt.Sprintf(ts.URL+"/api/v1/find?site=remark42&%s", tc.params)
body, code := get(t, url) body, code := get(t, url)
// bad-request cases are identified by their error response body rather than
// a "=bad" substring of the params: comment IDs are random UUIDs and one
// starting with "bad" (e.g. offset_id=bad49e60-...) would otherwise be
// misread as a bad request, making this test flaky.
expectedStatus := http.StatusOK expectedStatus := http.StatusOK
if strings.Contains(tc.params, "=bad") { if strings.Contains(tc.expectedBody, `"error":`) {
expectedStatus = http.StatusBadRequest expectedStatus = http.StatusBadRequest
} }
assert.Equal(t, expectedStatus, code) assert.Equal(t, expectedStatus, code)
assert.Contains(t, body, tc.expectedBody) assert.Contains(t, body, tc.expectedBody)
t.Log(body) t.Log(body)
// prevent hit limiter from engaging
time.Sleep(80 * time.Millisecond)
}) })
} }
} }
@@ -975,13 +987,26 @@ func TestRest_QR(t *testing.T) {
assert.Equal(t, "image/png", r.Header.Get("Content-Type")) assert.Equal(t, "image/png", r.Header.Get("Content-Type"))
assert.Equal(t, http.StatusOK, r.StatusCode) assert.Equal(t, http.StatusOK, r.StatusCode)
// compare the image // compare the decoded image rather than the encoded bytes: the pixels are what the endpoint
// promises, while the byte stream is whatever the toolchain's png encoder produces, and
// pinning that fails on a go release that changes it
fh, err := os.Open("testdata/qr_test.png") fh, err := os.Open("testdata/qr_test.png")
defer func() { assert.NoError(t, fh.Close()) }() defer func() { assert.NoError(t, fh.Close()) }()
assert.NoError(t, err) require.NoError(t, err)
img, err := io.ReadAll(fh)
assert.NoError(t, err) want, err := png.Decode(fh)
assert.Equal(t, img, bdy) require.NoError(t, err)
got, err := png.Decode(bytes.NewReader(bdy))
require.NoError(t, err, "the endpoint did not return a decodable png")
require.Equal(t, want.Bounds(), got.Bounds(), "the qr code is not the size it used to be")
for y := want.Bounds().Min.Y; y < want.Bounds().Max.Y; y++ {
for x := want.Bounds().Min.X; x < want.Bounds().Max.X; x++ {
if want.At(x, y) != got.At(x, y) {
t.Fatalf("the qr code differs at %d,%d: want %v, got %v", x, y, want.At(x, y), got.At(x, y))
}
}
}
} }
func TestRest_Info(t *testing.T) { func TestRest_Info(t *testing.T) {
@@ -1044,12 +1069,20 @@ func TestRest_LoadPictureRejectsPathTraversal(t *testing.T) {
defer teardown() defer teardown()
cases := []struct { cases := []struct {
name string name string
path string path string
wantStatus int
}{ }{
{name: "dotdot in user segment", path: "/api/v1/picture/../remark.db"}, // A literal ".." is normalized away by net/http.ServeMux before routing: the request
{name: "dotdot in id segment", path: "/api/v1/picture/dev_user/..%2Fremark.db"}, // is redirected to the cleaned path, which matches no picture route, so it never reaches
{name: "encoded dotdot in user segment", path: "/api/v1/picture/%2E%2E/remark.db"}, // loadPictureCtrl and resolves to 404. The traversal is neutralized at the router level
// (the cleaned path can only ever reach defined routes or the webRoot-bounded file server),
// so nothing is served either way.
{name: "dotdot in user segment", path: "/api/v1/picture/../remark.db", wantStatus: http.StatusNotFound},
// Encoded traversal is not cleaned by the router, so the handler's safePictureSegment
// validation is what rejects it, with 400.
{name: "dotdot in id segment", path: "/api/v1/picture/dev_user/..%2Fremark.db", wantStatus: http.StatusBadRequest},
{name: "encoded dotdot in user segment", path: "/api/v1/picture/%2E%2E/remark.db", wantStatus: http.StatusBadRequest},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
@@ -1059,7 +1092,7 @@ func TestRest_LoadPictureRejectsPathTraversal(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode) assert.Equal(t, c.wantStatus, resp.StatusCode)
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
s := string(body) s := string(body)
@@ -1193,8 +1226,8 @@ func TestRest_LoadPictureRejectsNonImage(t *testing.T) {
// (other fields like dataService, cache, commentFormatter are not touched here). // (other fields like dataService, cache, commentFormatter are not touched here).
p := &public{imageService: image.NewService(&imageStore, image.ServiceParams{})} p := &public{imageService: image.NewService(&imageStore, image.ServiceParams{})}
router := chi.NewRouter() router := routegroup.New(http.NewServeMux())
router.Get("/api/v1/picture/{user}/{id}", p.loadPictureCtrl) router.HandleFunc("GET /api/v1/picture/{user}/{id}", p.loadPictureCtrl)
ts := httptest.NewServer(router) ts := httptest.NewServer(router)
defer ts.Close() defer ts.Close()
+385 -275
View File
@@ -4,16 +4,19 @@ import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math/rand" "io/fs"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"testing/fstest"
"time" "time"
"github.com/go-pkgz/auth/v2" "github.com/go-pkgz/auth/v2"
@@ -22,6 +25,7 @@ import (
"github.com/go-pkgz/auth/v2/token" "github.com/go-pkgz/auth/v2/token"
cache "github.com/go-pkgz/lcw/v2" cache "github.com/go-pkgz/lcw/v2"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/go-pkgz/routegroup"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
@@ -36,6 +40,7 @@ import (
"github.com/umputun/remark42/backend/app/store/engine" "github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/image" "github.com/umputun/remark42/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/service" "github.com/umputun/remark42/backend/app/store/service"
"github.com/umputun/remark42/backend/app/webassets"
) )
// To generate a token, enter one of the tokens here into https://jwt.io, change the secret to one you're using in your test // To generate a token, enter one of the tokens here into https://jwt.io, change the secret to one you're using in your test
@@ -68,22 +73,319 @@ func TestRest_FileServer(t *testing.T) {
_ = os.Remove(testHTMLFile) _ = os.Remove(testHTMLFile)
} }
// TestRest_FileServerStaticAssets covers the static file server behaviors that are
// sensitive to the router: the bare /web -> /web/ redirect, cache headers applied to
// served assets, 404 for missing files, and the directory-listing block.
func TestRest_FileServerStaticAssets(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
require.NoError(t, os.WriteFile(srv.WebRoot+"/asset-test.html", []byte("static body"), 0o600))
require.NoError(t, os.MkdirAll(srv.WebRoot+"/subdir-test", 0o700))
defer func() {
_ = os.Remove(srv.WebRoot + "/asset-test.html")
_ = os.RemoveAll(srv.WebRoot + "/subdir-test")
}()
noRedirect := http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
defer noRedirect.CloseIdleConnections()
t.Run("bare /web redirects to /web/", func(t *testing.T) {
resp, err := noRedirect.Get(ts.URL + "/web")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode)
assert.Equal(t, "/web/", resp.Header.Get("Location"))
})
t.Run("serves an existing asset with cache headers", func(t *testing.T) {
resp, err := noRedirect.Get(ts.URL + "/web/asset-test.html")
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "static body", string(body))
assert.NotEmpty(t, resp.Header.Get("Etag"), "cacheControl must set an Etag on served assets")
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age", "cacheControl must set max-age on served assets")
})
t.Run("missing asset returns 404", func(t *testing.T) {
_, code := get(t, ts.URL+"/web/does-not-exist.html")
assert.Equal(t, http.StatusNotFound, code)
})
t.Run("directory listing is blocked", func(t *testing.T) {
resp, err := noRedirect.Get(ts.URL + "/web/subdir-test/")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "directory listings must be blocked")
})
}
// TestRest_FileServerBackendAssets covers the assets embedded in the binary and the rule that a
// name the frontend build provides is served from there instead. WebRoot is a fresh empty
// directory so the frontend side is known, rather than the shared temp dir startupT defaults to.
func TestRest_FileServerBackendAssets(t *testing.T) {
ts, srv, teardown := startupT(t, func(srv *Rest) { srv.WebRoot = t.TempDir() })
defer teardown()
t.Run("serves every embedded asset byte for byte", func(t *testing.T) {
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
t.Run(name, func(t *testing.T) {
want, err := fs.ReadFile(webassets.FS, name)
require.NoError(t, err)
body, code := get(t, ts.URL+"/web/"+name)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, string(want), body, "the bytes must come from the embedded assets")
})
}
})
t.Run("serves the image with its own content type", func(t *testing.T) {
resp, err := http.Get(ts.URL + "/web/400x400.jpeg")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type"))
})
t.Run("head is served", func(t *testing.T) {
resp, err := http.Head(ts.URL + "/web/privacy.html")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("frontend output wins over the embedded copy", func(t *testing.T) {
require.NoError(t, os.WriteFile(srv.WebRoot+"/privacy.html", []byte("operator's own policy"), 0o600))
t.Cleanup(func() { _ = os.Remove(srv.WebRoot + "/privacy.html") })
body, code := get(t, ts.URL+"/web/privacy.html")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, "operator's own policy", body)
})
t.Run("traversal out of the asset root is refused", func(t *testing.T) {
for _, p := range []string{"/web/../../etc/passwd", "/web/..%2f..%2fetc%2fpasswd", "/web/%2e%2e/%2e%2e/etc/passwd"} {
t.Run(p, func(t *testing.T) {
body, code := get(t, ts.URL+p)
assert.NotContains(t, body, "root:", "must never serve a file outside the served roots")
assert.NotEqual(t, http.StatusInternalServerError, code, "a rejected name must not surface as 500")
})
}
})
t.Run("missing in both still returns 404", func(t *testing.T) {
_, code := get(t, ts.URL+"/web/neither-source-has-this.html")
assert.Equal(t, http.StatusNotFound, code)
})
}
// TestRest_FileServerEmbeddedFrontend covers the branch taken when no web root exists on disk,
// which is how the released binary runs. The frontend stands in for the copy embedded at
// app/cmd/web, so a name it provides and a name only the assets provide are both exercised.
func TestRest_FileServerEmbeddedFrontend(t *testing.T) {
frontend := fstest.MapFS{"index.html": {Data: []byte("embedded frontend index")}}
router := routegroup.New(http.NewServeMux())
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", "https://remark.example.com")
ts := httptest.NewServer(router)
defer ts.Close()
t.Run("serves the embedded frontend", func(t *testing.T) {
body, code := get(t, ts.URL+"/web/index.html")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, "embedded frontend index", body)
})
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
t.Run("falls back to "+name, func(t *testing.T) {
want, err := fs.ReadFile(webassets.FS, name)
require.NoError(t, err)
body, code := get(t, ts.URL+"/web/"+name)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, string(want), body)
})
}
t.Run("a name neither source has is missing", func(t *testing.T) {
_, code := get(t, ts.URL+"/web/nothing-here.html")
assert.Equal(t, http.StatusNotFound, code)
})
t.Run("a name the operating system rejects is missing, not an error", func(t *testing.T) {
_, code := get(t, ts.URL+"/web/a%00b.html")
assert.Equal(t, http.StatusNotFound, code)
})
}
// TestRest_FileServerRoutesEmbedded drives the whole router the released binary runs: no web root
// on disk, and the frontend read from WebFS. It is what pins the web/ prefix routes() strips, which
// a test calling addFileServer directly cannot see.
func TestRest_FileServerRoutesEmbedded(t *testing.T) {
frontend := fstest.MapFS{
"web/index.html": {Data: []byte("embedded index")},
"web/iframe.html": {Data: []byte("embedded iframe")},
"web/remark.mjs": {Data: []byte("embedded bundle")},
}
ts, _, teardown := startupT(t, func(srv *Rest) {
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
srv.WebFS = frontend
})
defer teardown()
t.Run("serves the frontend from under the web prefix", func(t *testing.T) {
for name, want := range map[string]string{
"index.html": "embedded index",
"iframe.html": "embedded iframe",
"remark.mjs": "embedded bundle",
} {
t.Run(name, func(t *testing.T) {
body, code := get(t, ts.URL+"/web/"+name)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, want, body)
})
}
})
t.Run("the prefix is stripped rather than exposed", func(t *testing.T) {
_, code := get(t, ts.URL+"/web/web/index.html")
assert.Equal(t, http.StatusNotFound, code, "the web/ prefix must not be reachable as a path")
})
t.Run("the embedded assets still answer alongside it", func(t *testing.T) {
want, err := fs.ReadFile(webassets.FS, "privacy.html")
require.NoError(t, err)
body, code := get(t, ts.URL+"/web/privacy.html")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, string(want), body)
})
}
// refusingSubFS is an fs.FS whose Sub refuses, which is the only way fs.Sub returns a nil
// filesystem. routes() has to survive it, since a nil frontend would panic on the first request.
type refusingSubFS struct{}
func (refusingSubFS) Open(name string) (fs.File, error) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
func (refusingSubFS) Sub(string) (fs.FS, error) { return nil, errors.New("refused") }
// TestRest_FileServerFrontendSourceRefused covers the branch where the frontend source cannot be
// sub-rooted: /web must keep serving the embedded assets rather than panicking.
func TestRest_FileServerFrontendSourceRefused(t *testing.T) {
ts, _, teardown := startupT(t, func(srv *Rest) {
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
srv.WebFS = refusingSubFS{}
})
defer teardown()
t.Run("the embedded assets still serve", func(t *testing.T) {
want, err := fs.ReadFile(webassets.FS, "privacy.html")
require.NoError(t, err)
body, code := get(t, ts.URL+"/web/privacy.html")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, string(want), body)
})
t.Run("a frontend name is missing rather than fatal", func(t *testing.T) {
_, code := get(t, ts.URL+"/web/iframe.html")
assert.Equal(t, http.StatusNotFound, code)
})
}
// TestRest_RejectHeadOnDestructiveGET verifies that HEAD is blocked on the state-mutating
// GET routes (which stdlib http.ServeMux would otherwise route to the GET handler) while
// still being served for safe, read-only routes.
func TestRest_RejectHeadOnDestructiveGET(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
client := http.Client{}
defer client.CloseIdleConnections()
t.Run("HEAD is rejected on a destructive GET route", func(t *testing.T) {
req, err := http.NewRequest(http.MethodHead, ts.URL+"/api/v1/admin/deleteme?site=remark42", http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach a state-mutating GET handler")
assert.Equal(t, "GET", resp.Header.Get("Allow"), "405 must carry an Allow header")
})
t.Run("HEAD is rejected on the email unsubscribe route", func(t *testing.T) {
// emailUnsubscribeCtrl deletes the user's email subscription on GET, so HEAD (which
// ServeMux would route to the GET handler) must be rejected before it runs
resp, err := client.Head(ts.URL + "/email/unsubscribe.html?site=remark42")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach the email-unsubscribe handler")
assert.Equal(t, "GET, POST", resp.Header.Get("Allow"), "Allow must list every method the resource supports")
})
t.Run("HEAD still works on a safe read-only route", func(t *testing.T) {
resp, err := client.Head(ts.URL + "/api/v1/config?site=remark42")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD must still be served for safe read-only routes")
})
t.Run("wrong method on a known route returns 405 with Allow", func(t *testing.T) {
// method-in-pattern is new under ServeMux; a wrong method on a known route must
// still yield 405 with the allowed methods advertised
resp, err := client.Post(ts.URL+"/api/v1/config?site=remark42", "application/json", http.NoBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Allow"), "GET", "405 must advertise the allowed methods")
})
}
// TestRest_AvatarMounts verifies both avatar mounts (root /avatar/ and /api/v1/avatar/)
// still route to the avatar handler after the chi Mount -> ServeMux Handle rewiring,
// rather than falling through to a router 404.
func TestRest_AvatarMounts(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
for _, path := range []string{"/api/v1/avatar/nonexistent.image", "/avatar/nonexistent.image"} {
t.Run(path, func(t *testing.T) {
body, code := get(t, ts.URL+path)
// the avatar handler responds (403 "can't load avatar"), not a router 404
assert.Equal(t, http.StatusForbidden, code, "avatar mount must reach the avatar handler")
assert.Contains(t, body, "can't load avatar", "request must reach the avatar handler, not a routing 404")
})
}
}
func TestRest_Shutdown(t *testing.T) { func TestRest_Shutdown(t *testing.T) {
srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}} srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}}
port := chooseUnusedPort(t)
done := make(chan bool) done := make(chan bool)
// without waiting for channel close at the end goroutine will stay alive after test finish // without waiting for channel close at the end goroutine will stay alive after test finish
// which would create data race with next test // which would create data race with next test
go func() { go func() {
time.Sleep(200 * time.Millisecond) srv.Run("127.0.0.1", port)
srv.Shutdown()
close(done) close(done)
}() }()
st := time.Now() defer srv.Shutdown() // a failed readiness wait must not leave srv.Run behind for goleak
srv.Run("127.0.0.1", 0) waitForServerStart(t, port)
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms") srv.Shutdown()
<-done
select {
case <-done:
case <-time.After(serverStopTimeout):
t.Fatal("rest server did not stop after Shutdown")
}
} }
func TestRest_filterComments(t *testing.T) { func TestRest_filterComments(t *testing.T) {
@@ -102,7 +404,7 @@ func TestRest_filterComments(t *testing.T) {
} }
func TestRest_RunStaticSSLMode(t *testing.T) { func TestRest_RunStaticSSLMode(t *testing.T) {
sslPort := chooseRandomUnusedPort() sslPort := chooseUnusedPort(t)
srv := Rest{ srv := Rest{
Authenticator: auth.NewService(auth.Opts{ Authenticator: auth.NewService(auth.Opts{
AvatarStore: avatar.NewLocalFS("/tmp"), AvatarStore: avatar.NewLocalFS("/tmp"),
@@ -119,12 +421,12 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
} }
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
go func() { go func() {
srv.Run("", port) srv.Run("", port)
}() }()
waitForHTTPSServerStart(sslPort) waitForServerStart(t, sslPort, port)
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
@@ -157,7 +459,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
} }
func TestRest_RunAutocertModeHTTPOnly(t *testing.T) { func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
sslPort := chooseRandomUnusedPort() sslPort := chooseUnusedPort(t)
srv := Rest{ srv := Rest{
Authenticator: &auth.Service{}, Authenticator: &auth.Service{},
ImageProxy: &proxy.Image{}, ImageProxy: &proxy.Image{},
@@ -168,13 +470,13 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
} }
port := chooseRandomUnusedPort() port := chooseUnusedPort(t)
go func() { go func() {
// can't check https server locally, just only http server // can't check https server locally, just only http server
srv.Run("", port) srv.Run("", port)
}() }()
waitForHTTPSServerStart(sslPort) waitForServerStart(t, sslPort, port)
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
@@ -193,28 +495,6 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
srv.Shutdown() srv.Shutdown()
} }
func TestRest_rejectAnonUser(t *testing.T) {
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "Hello")
}))))
defer ts.Close()
resp, err := http.Get(ts.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in")
resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected")
resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode, "real user")
}
func Test_URLKey(t *testing.T) { func Test_URLKey(t *testing.T) {
tbl := []struct { tbl := []struct {
url string url string
@@ -284,37 +564,6 @@ func TestRest_parseError(t *testing.T) {
} }
} }
func TestRest_cacheControl(t *testing.T) {
tbl := []struct {
url string
version string
exp time.Duration
etag string
maxAge int
}{
{"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600},
{"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000},
{"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600},
{"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, http.NoBody)
w := httptest.NewRecorder()
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
t.Logf("%+v", resp.Header)
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
})
}
}
func TestRest_frameAncestors(t *testing.T) { func TestRest_frameAncestors(t *testing.T) {
ts, _, teardown := startupT(t, func(o *Rest) { ts, _, teardown := startupT(t, func(o *Rest) {
o.AllowedAncestors = []string{"'self'", "https://example.com"} o.AllowedAncestors = []string{"'self'", "https://example.com"}
@@ -324,9 +573,12 @@ func TestRest_frameAncestors(t *testing.T) {
client := http.Client{} client := http.Client{}
resp, err := client.Get(ts.URL + "/web/index.html") resp, err := client.Get(ts.URL + "/web/index.html")
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;") assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
// until the test ends, so the body has to be released before the server is torn down here
require.NoError(t, resp.Body.Close())
client.CloseIdleConnections()
teardown() teardown()
// test case without frame-ancestors // test case without frame-ancestors
@@ -341,157 +593,11 @@ func TestRest_frameAncestors(t *testing.T) {
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors *;") assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors *;")
} }
// TestRest_apiCSP locks in that /api/v1/* responses get a strict default-src 'none'
// override regardless of what the global CSP allows. The widget HTML pages
// (/web/*.html) still get the global CSP (with 'unsafe-inline' for bootstrap),
// so the test asserts the two policies diverge across origins.
func TestRest_apiCSP(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
client := http.Client{}
// JSON API endpoint — must carry the strict policy
resp, err := client.Get(ts.URL + "/api/v1/config")
require.NoError(t, err)
defer resp.Body.Close()
csp := resp.Header.Get("Content-Security-Policy")
assert.Contains(t, csp, "default-src 'none'",
"API responses must override the global CSP with default-src 'none'; got %q", csp)
assert.Contains(t, csp, "sandbox", "API CSP must include sandbox; got %q", csp)
assert.NotContains(t, csp, "'unsafe-inline'",
"API CSP must not allow inline scripts/styles; got %q", csp)
// RSS/XML endpoint — same strict policy, and the XML response itself must still be served
respRSS, err := client.Get(ts.URL + "/api/v1/rss/site?site=remark42")
require.NoError(t, err)
defer respRSS.Body.Close()
assert.Equal(t, http.StatusOK, respRSS.StatusCode, "RSS must still respond OK under strict CSP")
cspRSS := respRSS.Header.Get("Content-Security-Policy")
assert.Contains(t, cspRSS, "default-src 'none'", "RSS responses must carry the strict API CSP")
assert.Contains(t, cspRSS, "sandbox", "RSS CSP must include sandbox")
// widget HTML — must keep the global CSP (unchanged, lax to support inline bootstrap)
resp2, err := client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
defer resp2.Body.Close()
csp2 := resp2.Header.Get("Content-Security-Policy")
assert.Contains(t, csp2, "'unsafe-inline'",
"widget HTML CSP must keep unsafe-inline for bootstrap; got %q", csp2)
}
// check CSP, img-src should be 'self' with proxy enabled and * without it
func TestRest_securityHeaders(t *testing.T) {
ts, _, teardown := startupT(t)
// with proxy disabled
client := http.Client{}
resp, err := client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;")
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
teardown()
// check CSP with proxy enabled
ts, _, teardown = startupT(t, func(srv *Rest) {
srv.ExternalImageProxy = true
})
defer teardown()
resp, err = client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src 'self';")
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
}
func TestRest_subscribersOnly(t *testing.T) {
paidSubUser := &token.User{}
paidSubUser.SetPaidSub(true)
tbl := []struct {
subsOnly bool
user token.User
setUser bool
status int
}{
{true, token.User{}, false, http.StatusUnauthorized},
{true, token.User{}, true, http.StatusForbidden},
{false, token.User{}, false, http.StatusOK},
{false, token.User{}, true, http.StatusOK},
{true, *paidSubUser, true, http.StatusOK},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
if tt.setUser {
req = token.SetUserInfo(req, tt.user)
}
w := httptest.NewRecorder()
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, tt.status, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
})
}
}
func Test_validEmailAuth(t *testing.T) {
tbl := []struct {
req string
status int
}{
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK},
{"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusForbidden},
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someonelooong+loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", http.StatusForbidden},
{"/auth/twitter/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun%example.com", http.StatusOK},
{"/auth/email/login?site=remark42&address=umputun+example.com&user=someone", http.StatusForbidden},
{"/auth/email/login?site=bad!site&address=umputun%example.com&user=someone", http.StatusForbidden},
{"/auth/email/login?site=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongsite&address=umputun%example.com&user=someone", http.StatusForbidden},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
w := httptest.NewRecorder()
h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, tt.status, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
})
}
}
// randomPath pick a file or folder name which is not in use for sure
func randomPath(tempDir, basename, suffix string) (string, error) {
for range 10 {
fname := fmt.Sprintf("/%s/%s-%d%s", tempDir, basename, rand.Int31(), suffix)
fmt.Printf("fname %q", fname)
_, err := os.Stat(fname)
if err != nil {
return fname, nil
}
}
return "", fmt.Errorf("cannot create temp file in %s", tempDir)
}
// startupT runs fully configured testing server // startupT runs fully configured testing server
// srvHook is an optional func to set some Rest param after the creation but prior to Run // srvHook is an optional func to set some Rest param after the creation but prior to Run
func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, srv *Rest, teardown func()) { func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, srv *Rest, teardown func()) {
tmp := os.TempDir() tmp := os.TempDir()
testDB, err := randomPath(tmp, "test-remark", ".db") testDB := filepath.Join(t.TempDir(), "test-remark.db") // per-test dir, removed when the test ends
require.NoError(t, err)
_ = os.RemoveAll(tmp + "/ava-remark42") _ = os.RemoveAll(tmp + "/ava-remark42")
_ = os.RemoveAll(tmp + "/pics-remark42") _ = os.RemoveAll(tmp + "/pics-remark42")
@@ -572,7 +678,6 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
teardown = func() { teardown = func() {
ts.Close() ts.Close()
require.NoError(t, srv.DataService.Close()) require.NoError(t, srv.DataService.Close())
_ = os.Remove(testDB)
_ = os.RemoveAll(tmp + "/ava-remark42") _ = os.RemoveAll(tmp + "/ava-remark42")
_ = os.RemoveAll(tmp + "/pics-remark42") _ = os.RemoveAll(tmp + "/pics-remark42")
} }
@@ -580,6 +685,44 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
return ts, srv, teardown return ts, srv, teardown
} }
const (
// outer bound before a wait is called a hang, generous enough for a loaded CI runner
waitTimeout = 30 * time.Second
pollInterval = 10 * time.Millisecond
// budget for a server to stop once asked, tight enough to catch a shutdown that hangs
serverStopTimeout = 10 * time.Second
// connect budget for a single probe, kept off the poll interval so a slow loopback connect
// on a loaded runner does not look like a server that is not listening
probeDialTimeout = time.Second
// window to prove something did not happen
notifySettle = 300 * time.Millisecond
// poll interval for waits that issue an HTTP request. the admin routes allow 10 req/s and
// the open ones 100 in tests, so this stays below the tighter of the two and the poll
// cannot manufacture the 429s it would then have to interpret
httpPoll = 150 * time.Millisecond
)
// waitForCount blocks until got reaches want, failing the test with the last value it saw.
// for work that is delivered asynchronously, such as notifications reaching a mock destination
func waitForCount(t *testing.T, want int, got func() int, msgAndArgs ...any) {
t.Helper()
require.EventuallyWithT(t, func(c *assert.CollectT) {
assert.Equal(c, want, got(), msgAndArgs...)
}, waitTimeout, pollInterval)
}
// waitForCountSettled waits for got to reach want and then holds it there, so a delivery
// arriving late is caught rather than passing because the count was read the instant it matched
func waitForCountSettled(t *testing.T, want int, got func() int, msgAndArgs ...any) {
t.Helper()
waitForCount(t, want, got, msgAndArgs...)
require.Never(t, func() bool { return got() != want }, notifySettle, pollInterval, msgAndArgs...)
}
// fake auth middleware make user authenticated and uses query's fake_id for ID and fake_name for Name // fake auth middleware make user authenticated and uses query's fake_id for ID and fake_name for Name
func fakeAuth(next http.Handler) http.Handler { func fakeAuth(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
@@ -603,7 +746,7 @@ func get(t *testing.T, url string) (response string, statusCode int) {
return string(body), r.StatusCode return string(body), r.StatusCode
} }
func sendReq(_ *testing.T, r *http.Request, tkn string) (*http.Response, error) { func sendReq(r *http.Request, tkn string) (*http.Response, error) {
client := http.Client{Timeout: 5 * time.Second} client := http.Client{Timeout: 5 * time.Second}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
if tkn != "" { if tkn != "" {
@@ -685,7 +828,6 @@ func addCommentGetCreatedTime(t *testing.T, c store.Comment, ts *httptest.Server
crResp := R.JSON{} crResp := R.JSON{}
err = json.Unmarshal(b, &crResp) err = json.Unmarshal(b, &crResp)
require.NoError(t, err) require.NoError(t, err)
time.Sleep(time.Nanosecond * 10)
created, err = time.Parse(time.RFC3339, crResp["time"].(string)) created, err = time.Parse(time.RFC3339, crResp["time"].(string))
require.NoError(t, err) require.NoError(t, err)
return crResp["id"].(string), created return crResp["id"].(string), created
@@ -697,37 +839,41 @@ func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
} }
func requireAdminOnly(t *testing.T, req *http.Request) { func requireAdminOnly(t *testing.T, req *http.Request) {
resp, err := sendReq(t, req, "") // no-auth user resp, err := sendReq(req, "") // no-auth user
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
resp, err = sendReq(t, req, devToken) // non-admin user resp, err = sendReq(req, devToken) // non-admin user
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, http.StatusForbidden, resp.StatusCode)
} }
func chooseRandomUnusedPort() (port int) { // chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
for range 10 { // collision between concurrently running package test binaries very unlikely
port = 40000 + int(rand.Int31n(10000)) func chooseUnusedPort(t *testing.T) int {
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil { t.Helper()
_ = ln.Close() ln, err := net.Listen("tcp", ":0")
break require.NoError(t, err, "no free port available")
} port := ln.Addr().(*net.TCPAddr).Port
} require.NoError(t, ln.Close())
return port return port
} }
func waitForHTTPSServerStart(port int) { // waitForServerStart blocks until something accepts on every listed port, failing the test
// wait for up to 3 seconds for HTTPS server to start // naming the port that never came up
for range 300 { func waitForServerStart(t *testing.T, ports ...int) {
time.Sleep(time.Millisecond * 10) t.Helper()
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10) for _, port := range ports {
if conn != nil { require.Eventually(t, func() bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), probeDialTimeout)
if err != nil {
return false
}
_ = conn.Close() _ = conn.Close()
break return true
} }, waitTimeout, pollInterval, "server on port %d didn't start", port)
} }
} }
@@ -736,45 +882,9 @@ func TestMain(m *testing.M) {
m, m,
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159 // this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"), goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
// it ends on its own, but a binary that finishes inside that window is reported as leaking
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
) )
} }
// TestRest_matchSiteID reproduces the multi-tenant isolation gap in the matchSiteID
// middleware. Before the fix, the check `if siteID != "" && user.SiteID != siteID`
// silently allowed any authenticated request that omitted the ?site= query param.
// On admin and user-mutation routes this meant the cross-site check was bypassable
// just by dropping the parameter. The fix requires ?site= to be present and to match
// the user's bound site.
func TestRest_matchSiteID(t *testing.T) {
wrapped := matchSiteID(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
cases := []struct {
name string
userSite string
query string
want int
}{
{name: "matching site allowed", userSite: "site-a", query: "?site=site-a", want: http.StatusOK},
{name: "mismatched site forbidden", userSite: "site-a", query: "?site=site-b", want: http.StatusForbidden},
{name: "missing site param rejected", userSite: "site-a", query: "", want: http.StatusForbidden},
{name: "empty site param rejected", userSite: "site-a", query: "?site=", want: http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = rest.SetUserInfo(r, store.User{ID: "u", Name: "u", SiteID: c.userSite})
wrapped.ServeHTTP(w, r)
})
ts := httptest.NewServer(h)
defer ts.Close()
resp, err := http.Get(ts.URL + c.query)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, c.want, resp.StatusCode)
})
}
}
+65 -61
View File
@@ -14,22 +14,29 @@ import (
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
) )
// rssPubTime returns a second-aligned base timestamp and formats it the way the feed does, so
// comment pubDates are pinned rather than dependent on when in the second the test runs.
func rssPubTime() (base time.Time, pubDate string) {
base = time.Now().Truncate(time.Second)
return base, base.Format(time.RFC1123Z)
}
func TestServer_RssPost(t *testing.T) { func TestServer_RssPost(t *testing.T) {
ts, rst, teardown := startupT(t) ts, rst, teardown := startupT(t)
defer teardown() defer teardown()
waitOnSecChange() base, pubDate := rssPubTime()
c1 := store.Comment{ c1 := store.Comment{
ID: "1234567890", ID: "1234567890",
Text: "test 123", Text: "test 123",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"}, Timestamp: base,
User: store.User{ID: "u1", Name: "developer one"}, Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
} }
id1, err := rst.DataService.Create(c1) id1, err := rst.DataService.Create(c1)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "1234567890", id1) assert.Equal(t, "1234567890", id1)
pubDate := time.Now().Format(time.RFC1123Z)
res, code := get(t, ts.URL+"/api/v1/rss/post?site=remark42&url=https://radio-t.com/blah1") res, code := get(t, ts.URL+"/api/v1/rss/post?site=remark42&url=https://radio-t.com/blah1")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
@@ -63,21 +70,21 @@ func TestServer_RssSite(t *testing.T) {
ts, rst, teardown := startupT(t) ts, rst, teardown := startupT(t)
defer teardown() defer teardown()
waitOnSecChange() base, pubDate := rssPubTime()
pubDate := time.Now().Format(time.RFC1123Z)
c1 := store.Comment{ c1 := store.Comment{
ID: "comment-id-1", ID: "comment-id-1",
Text: "test 123", Text: "test 123",
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"}, Timestamp: base,
User: store.User{ID: "u1", Name: "developer one"}, Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
} }
c2 := store.Comment{ c2 := store.Comment{
ID: "comment-id-2", ID: "comment-id-2",
Text: "xyz test", Text: "xyz test",
Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "remark42"}, Timestamp: base.Add(time.Millisecond),
User: store.User{ID: "u1", Name: "developer one"}, Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
} }
_, err := rst.DataService.Create(c1) _, err := rst.DataService.Create(c1)
@@ -126,22 +133,22 @@ func TestServer_RssWithReply(t *testing.T) {
ts, rst, teardown := startupT(t) ts, rst, teardown := startupT(t)
defer teardown() defer teardown()
waitOnSecChange() base, pubDate := rssPubTime()
pubDate := time.Now().Format(time.RFC1123Z)
c1 := store.Comment{ c1 := store.Comment{
ID: "comment-id-1", ID: "comment-id-1",
Text: "test 123", Text: "test 123",
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"}, Timestamp: base,
User: store.User{ID: "u1", Name: "developer one"}, Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
} }
c2 := store.Comment{ c2 := store.Comment{
ID: "comment-id-2", ID: "comment-id-2",
ParentID: "comment-id-1", ParentID: "comment-id-1",
Text: "xyz test", Text: "xyz test",
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"}, Timestamp: base.Add(time.Millisecond),
User: store.User{ID: "u1", Name: "developer one"}, Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
} }
_, err := rst.DataService.Create(c1) _, err := rst.DataService.Create(c1)
@@ -186,42 +193,45 @@ func TestServer_RssReplies(t *testing.T) {
ts, srv, teardown := startupT(t) ts, srv, teardown := startupT(t)
defer teardown() defer teardown()
waitOnSecChange() base, pubDate := rssPubTime()
pubDate := time.Now().Format(time.RFC1123Z)
c1 := store.Comment{ c1 := store.Comment{
ID: "comment-1", ID: "comment-1",
Text: "c1", Text: "c1",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"}, Timestamp: base,
User: store.User{ID: "user1", Name: "user1"}, Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "user1", Name: "user1"},
} }
c2 := store.Comment{ c2 := store.Comment{
ID: "comment-2", ID: "comment-2",
Text: "reply to c1 from user2", Text: "reply to c1 from user2",
ParentID: "comment-1", ParentID: "comment-1",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"}, Timestamp: base.Add(time.Millisecond),
User: store.User{ID: "user2", Name: "user2"}, Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "user2", Name: "user2"},
} }
c3 := store.Comment{ c3 := store.Comment{
ID: "comment-3", ID: "comment-3",
Text: "reply to c1 from user3", Text: "reply to c1 from user3",
ParentID: "comment-1", ParentID: "comment-1",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"}, Timestamp: base.Add(2 * time.Millisecond),
User: store.User{ID: "user3", Name: "user3"}, Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "user3", Name: "user3"},
} }
c4 := store.Comment{ c4 := store.Comment{
ID: "comment-4", ID: "comment-4",
Text: "reply to c2 from developer one", Text: "reply to c2 from developer one",
ParentID: "comment-2", ParentID: "comment-2",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"}, Timestamp: base.Add(3 * time.Millisecond),
User: store.User{ID: "dev", Name: "developer one"}, Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "dev", Name: "developer one"},
} }
c5 := store.Comment{ c5 := store.Comment{
ID: "comment-5", ID: "comment-5",
Text: "developer one", Text: "developer one",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"}, Timestamp: base.Add(4 * time.Millisecond),
User: store.User{ID: "dev", Name: "developer one"}, Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "dev", Name: "developer one"},
} }
_, err := srv.DataService.Create(c1) _, err := srv.DataService.Create(c1)
@@ -270,12 +280,6 @@ func TestServer_RssReplies(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, code) assert.Equal(t, http.StatusBadRequest, code)
} }
func waitOnSecChange() {
for time.Now().Nanosecond() >= 100000000 {
time.Sleep(10 * time.Nanosecond)
}
}
// clean formatting, i.e. multiple spaces, \t, \n // clean formatting, i.e. multiple spaces, \t, \n
func cleanRssFormatting(expected, actual string) (cleanExp, cleanAct string) { func cleanRssFormatting(expected, actual string) (cleanExp, cleanAct string) {
reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`) reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`)
+37 -18
View File
@@ -2,16 +2,16 @@ package api
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"golang.org/x/crypto/acme/autocert"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/go-pkgz/routegroup"
"golang.org/x/crypto/acme/autocert"
) )
// sslMode defines ssl mode for rest server // sslMode defines ssl mode for rest server
@@ -40,13 +40,13 @@ type SSLConfig struct {
// httpToHTTPSRouter creates new router which does redirect from http to https server // httpToHTTPSRouter creates new router which does redirect from http to https server
// with default middlewares. Used in 'static' ssl mode. // with default middlewares. Used in 'static' ssl mode.
func (s *Rest) httpToHTTPSRouter() chi.Router { func (s *Rest) httpToHTTPSRouter() http.Handler {
log.Printf("[DEBUG] create https-to-http redirect routes") log.Printf("[DEBUG] create http-to-https redirect routes")
router := chi.NewRouter() router := routegroup.New(http.NewServeMux())
router.Use(middleware.RealIP, R.Recoverer(log.Default())) router.Use(R.Recoverer(log.Default()))
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second)) router.Use(R.Throttle(1000), R.Timeout(60*time.Second))
router.Handle("/*", s.redirectHandler()) router.Handle("/", s.redirectHandler())
return router return router
} }
@@ -54,26 +54,45 @@ func (s *Rest) httpToHTTPSRouter() chi.Router {
// with default middlewares. This part is necessary to obtain certificate from LE. // with default middlewares. This part is necessary to obtain certificate from LE.
// If it receives not a acme challenge it performs redirect to https server. // If it receives not a acme challenge it performs redirect to https server.
// Used in 'auto' ssl mode. // Used in 'auto' ssl mode.
func (s *Rest) httpChallengeRouter(m *autocert.Manager) chi.Router { func (s *Rest) httpChallengeRouter(m *autocert.Manager) http.Handler {
log.Printf("[DEBUG] create http-challenge routes") log.Printf("[DEBUG] create http-challenge routes")
router := chi.NewRouter() router := routegroup.New(http.NewServeMux())
router.Use(middleware.RealIP, R.Recoverer(log.Default())) router.Use(R.Recoverer(log.Default()))
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second)) router.Use(R.Throttle(1000), R.Timeout(60*time.Second))
router.Handle("/*", m.HTTPHandler(s.redirectHandler())) router.Handle("/", m.HTTPHandler(s.redirectHandler()))
return router return router
} }
func (s *Rest) redirectHandler() http.Handler { func (s *Rest) redirectHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
newURL := s.RemarkURL + r.URL.Path newURL, err := s.redirectURL(r)
if r.URL.RawQuery != "" { if err != nil {
newURL += "?" + r.URL.RawQuery log.Printf("[WARN] failed to build redirect URL, %s", err)
http.Error(w, "invalid redirect URL", http.StatusInternalServerError)
return
} }
http.Redirect(w, r, newURL, http.StatusTemporaryRedirect) http.Redirect(w, r, newURL, http.StatusTemporaryRedirect)
}) })
} }
func (s *Rest) redirectURL(r *http.Request) (string, error) {
baseURL, err := url.Parse(s.RemarkURL)
if err != nil {
return "", fmt.Errorf("parse remark URL: %w", err)
}
if baseURL.Scheme != "http" && baseURL.Scheme != "https" || baseURL.Host == "" {
return "", fmt.Errorf("remark URL must be absolute HTTP(S) URL")
}
basePath := strings.TrimRight(baseURL.Path, "/")
requestPath := "/" + strings.TrimLeft(r.URL.Path, "/")
baseURL.Path = basePath + requestPath
baseURL.RawQuery = r.URL.RawQuery
baseURL.Fragment = ""
return baseURL.String(), nil
}
func (s *Rest) makeAutocertManager() *autocert.Manager { func (s *Rest) makeAutocertManager() *autocert.Manager {
return &autocert.Manager{ return &autocert.Manager{
Prompt: autocert.AcceptTOS, Prompt: autocert.AcceptTOS,
+10
View File
@@ -40,6 +40,16 @@ func TestSSL_Redirect(t *testing.T) {
assert.Equal(t, "https://localhost:443/blah?param=1", resp.Header.Get("Location")) assert.Equal(t, "https://localhost:443/blah?param=1", resp.Header.Get("Location"))
} }
func TestSSL_RedirectURLKeepsConfiguredHost(t *testing.T) {
rest := Rest{RemarkURL: "https://localhost:443/base"}
req, err := http.NewRequest("GET", "http://example.com//evil.test/path?next=//evil.test", http.NoBody)
require.NoError(t, err)
redirectURL, err := rest.redirectURL(req)
require.NoError(t, err)
assert.Equal(t, "https://localhost:443/base/evil.test/path?next=//evil.test", redirectURL)
}
func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) { func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
rest := Rest{ rest := Rest{
RemarkURL: "https://localhost:443", RemarkURL: "https://localhost:443",
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"bytes"
"errors"
"io"
"io/fs"
"path/filepath"
"strings"
)
// webFiles serves /web from two sources: a name present in the frontend build is served from there,
// and any other name from the assets embedded in the binary.
type webFiles struct {
frontend fs.FS
embedded fs.FS
}
// Open resolves the name against both sources, and answers a missing .js with the .mjs sibling.
// The build stopped emitting .js while integrations still request it; the bundles carry no module
// syntax, so the same bytes serve both names.
func (w webFiles) Open(name string) (fs.File, error) {
// fs.ValidPath alone is not enough: it accepts names the operating system rejects, NUL among
// them, and os.DirFS turns those into fs.ErrInvalid, which renders as 500 rather than 404
if _, err := filepath.Localize(name); err != nil || !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
f, err := w.open(name)
if err == nil {
return f, nil
}
if !errors.Is(err, fs.ErrNotExist) || !strings.HasSuffix(name, ".js") {
return nil, err
}
alias, aliasErr := w.open(strings.TrimSuffix(name, ".js") + ".mjs")
if aliasErr == nil {
return alias, nil
}
if !errors.Is(aliasErr, fs.ErrNotExist) {
return nil, aliasErr
}
return nil, err
}
// open looks the name up in the frontend build first. Only a missing file falls through to the
// embedded assets; every other error is returned so an unreadable file keeps reporting as one
// rather than being replaced by the embedded copy or reported as missing.
func (w webFiles) open(name string) (fs.File, error) {
f, err := w.frontend.Open(name)
if err == nil {
return f, nil
}
if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
if name == "." {
// the embedded set is a flat list of files; only the frontend build answers for the
// directory itself, so a missing web root reports as missing rather than listing them
return nil, err
}
return w.embedded.Open(name)
}
// emptyFS stands in for a frontend source that could not be opened, so a misconfigured one serves
// nothing instead of panicking or serving the build at paths it does not belong at
type emptyFS struct{}
func (emptyFS) Open(name string) (fs.File, error) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
// remarkURLPlaceholder is what the frontend build carries wherever the instance URL belongs. The
// bundler cannot know that URL, so it emits this marker and every distribution fills it in: the
// docker image rewrites the files under the web root at container start, and the binary, which
// serves the build embedded in itself and has nothing to rewrite, does it here.
const remarkURLPlaceholder = "{% REMARK_URL %}"
// templatedFS fills the instance URL into the files carrying the placeholder. Without it the
// binary serves whatever the build baked in, which is a host no visitor can reach, and the widget
// falls back to it whenever a page omits remark_config.host.
type templatedFS struct {
fs fs.FS
remarkURL string
}
// Open substitutes in the file types the frontend templates, and hands everything else through
// untouched so images and stylesheets keep streaming from their original source
func (t templatedFS) Open(name string) (fs.File, error) {
f, err := t.fs.Open(name)
if err != nil || !templatedName(name) {
return f, err
}
info, err := f.Stat()
if err != nil || info.IsDir() {
return f, err
}
body, err := io.ReadAll(f)
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return nil, err
}
body = bytes.ReplaceAll(body, []byte(remarkURLPlaceholder), []byte(t.remarkURL))
return &memFile{Reader: bytes.NewReader(body), info: sizedInfo{FileInfo: info, size: int64(len(body))}}, nil
}
// templatedName reports whether the frontend templates this file type. It mirrors the set the
// docker image rewrites, so both distributions substitute in the same files
func templatedName(name string) bool {
switch filepath.Ext(name) {
case ".html", ".js", ".mjs":
return true
}
return false
}
// memFile is a substituted file held in memory. The file server needs a seeker to answer range
// requests and to sniff a content type, which a substituted body no longer has on disk
type memFile struct {
*bytes.Reader
info fs.FileInfo
}
func (f *memFile) Stat() (fs.FileInfo, error) { return f.info, nil }
func (f *memFile) Close() error { return nil }
// sizedInfo reports the length after substitution. The file server writes Content-Length from it,
// so reporting the length on disk would truncate the response or leave the client waiting
type sizedInfo struct {
fs.FileInfo
size int64
}
func (i sizedInfo) Size() int64 { return i.size }
+343
View File
@@ -0,0 +1,343 @@
package api
import (
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"testing/fstest"
"github.com/go-pkgz/routegroup"
"github.com/umputun/remark42/backend/app/webassets"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWebFiles_Open(t *testing.T) {
frontend := fstest.MapFS{
"both.html": {Data: []byte("from the frontend build")},
"only-frontend.html": {Data: []byte("frontend only")},
}
embedded := fstest.MapFS{
"both.html": {Data: []byte("from the embedded assets")},
"only-embedded.html": {Data: []byte("embedded only")},
}
w := webFiles{frontend: frontend, embedded: embedded}
tbl := []struct {
name string
lookup string
want string
wantErr error
}{
{name: "present in both is served from the frontend build", lookup: "both.html", want: "from the frontend build"},
{name: "frontend only", lookup: "only-frontend.html", want: "frontend only"},
{name: "embedded only", lookup: "only-embedded.html", want: "embedded only"},
{name: "missing in both", lookup: "neither.html", wantErr: fs.ErrNotExist},
}
for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
f, err := w.Open(tt.lookup)
if tt.wantErr != nil {
require.Error(t, err)
assert.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
defer f.Close()
b, err := io.ReadAll(f)
require.NoError(t, err)
assert.Equal(t, tt.want, string(b))
})
}
}
func TestWebFiles_OpenJSAlias(t *testing.T) {
frontend := fstest.MapFS{
"embed.mjs": {Data: []byte("module embed")},
"counter.js": {Data: []byte("operator's own counter")},
"counter.mjs": {Data: []byte("module counter")},
"widget.mjs": {Data: []byte("module widget")},
}
embedded := fstest.MapFS{
"legacy.mjs": {Data: []byte("module legacy")},
"widget.js": {Data: []byte("embedded widget")},
}
w := webFiles{frontend: frontend, embedded: embedded}
tbl := []struct {
name string
lookup string
want string
wantErr error
}{
{name: "missing js served from the mjs sibling", lookup: "embed.js", want: "module embed"},
{name: "alias reaches the embedded assets too", lookup: "legacy.js", want: "module legacy"},
{name: "a real js file wins over its sibling", lookup: "counter.js", want: "operator's own counter"},
{name: "an embedded js wins over a frontend sibling", lookup: "widget.js", want: "embedded widget"},
{name: "mjs is still served directly", lookup: "embed.mjs", want: "module embed"},
{name: "neither name present", lookup: "absent.js", wantErr: fs.ErrNotExist},
{name: "only js aliases, not other extensions", lookup: "embed.html", wantErr: fs.ErrNotExist},
}
for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
f, err := w.Open(tt.lookup)
if tt.wantErr != nil {
require.Error(t, err)
assert.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
defer f.Close()
b, err := io.ReadAll(f)
require.NoError(t, err)
assert.Equal(t, tt.want, string(b))
})
}
}
func TestWebFiles_OpenJSAliasNamesTheRequestedFile(t *testing.T) {
w := webFiles{frontend: fstest.MapFS{}, embedded: fstest.MapFS{}}
_, err := w.Open("absent.js")
require.Error(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist)
assert.Contains(t, err.Error(), "absent.js")
assert.NotContains(t, err.Error(), "absent.mjs")
}
func TestWebFiles_OpenJSAliasUnreadableSibling(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores file permissions")
}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "embed.mjs"), []byte("module embed"), 0o000))
w := webFiles{frontend: os.DirFS(dir), embedded: fstest.MapFS{}}
f, err := w.Open("embed.js")
require.Error(t, err)
assert.ErrorIs(t, err, fs.ErrPermission)
assert.NotErrorIs(t, err, fs.ErrNotExist, "an unreadable sibling must not render as 404")
if err == nil {
_ = f.Close()
}
}
// TestEmptyFS_ServesNothing pins the stand-in used when the frontend source cannot be opened:
// every name must report as missing rather than panicking, since it backs a nil-free fallback.
func TestEmptyFS_ServesNothing(t *testing.T) {
for _, name := range []string{".", "index.html", "web/index.html"} {
t.Run(name, func(t *testing.T) {
f, err := emptyFS{}.Open(name)
require.Error(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist)
assert.Nil(t, f)
})
}
}
// TestWebFiles_EmptyFrontendFallsThrough covers the shape routes() builds when fs.Sub refuses:
// the embedded assets must still answer even though the frontend source serves nothing.
func TestWebFiles_EmptyFrontendFallsThrough(t *testing.T) {
w := webFiles{frontend: emptyFS{}, embedded: webassets.FS}
want, err := fs.ReadFile(webassets.FS, "privacy.html")
require.NoError(t, err)
f, err := w.Open("privacy.html")
require.NoError(t, err)
defer f.Close()
got, err := io.ReadAll(f)
require.NoError(t, err)
assert.Equal(t, string(want), string(got))
}
// TestWebFiles_OpenRootIsNotListed keeps the embedded assets from being browsable: they answer
// for their own names only, so a web root that has gone missing reports as missing.
func TestWebFiles_OpenRootIsNotListed(t *testing.T) {
w := webFiles{frontend: os.DirFS(filepath.Join(t.TempDir(), "absent")), embedded: webassets.FS}
f, err := w.Open(".")
require.Error(t, err, "the embedded assets must not answer for the directory itself")
assert.ErrorIs(t, err, fs.ErrNotExist)
if err == nil {
_ = f.Close()
}
// the assets themselves still serve
f, err = w.Open("privacy.html")
require.NoError(t, err)
require.NoError(t, f.Close())
}
// TestWebFiles_OpenInvalidName pins that a name fs rejects reports as missing rather than invalid.
// os.DirFS returns fs.ErrInvalid for these, which http.FileServer renders as 500, so the check has
// to happen before the lookup. A memory filesystem cannot show this: it reports missing either way.
func TestWebFiles_OpenInvalidName(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "privacy.html"), []byte("frontend"), 0o600))
w := webFiles{frontend: os.DirFS(dir), embedded: webassets.FS}
for _, name := range []string{"../escape.html", "/etc/passwd", "a\x00b.html", "./privacy.html"} {
t.Run(name, func(t *testing.T) {
f, err := w.Open(name)
require.Error(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist)
assert.NotErrorIs(t, err, fs.ErrInvalid, "an invalid name must not surface as 500")
if err == nil {
_ = f.Close()
}
})
}
}
// TestWebFiles_OpenUnreadableFrontendFile pins the rule that only a missing file falls through:
// a frontend file that cannot be read must report that, not be masked by the embedded copy.
func TestWebFiles_OpenUnreadableFrontendFile(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores file permissions")
}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "privacy.html"), []byte("operator's own"), 0o000))
w := webFiles{
frontend: os.DirFS(dir),
embedded: fstest.MapFS{"privacy.html": {Data: []byte("built in")}},
}
f, err := w.Open("privacy.html")
require.Error(t, err, "an unreadable frontend file must not be replaced by the embedded copy")
assert.NotErrorIs(t, err, fs.ErrNotExist, "the error must stay a permission error so it does not render as 404")
assert.ErrorIs(t, err, fs.ErrPermission)
if err == nil {
_ = f.Close()
}
}
func TestTemplatedFS_SubstitutesTheInstanceURL(t *testing.T) {
const placeholder = "host: '" + remarkURLPlaceholder + "'"
source := fstest.MapFS{
"iframe.html": {Data: []byte(placeholder)},
"embed.mjs": {Data: []byte(placeholder)},
"embed.js": {Data: []byte(placeholder)},
"remark.css": {Data: []byte(placeholder)},
"nothing.html": {Data: []byte("no marker here")},
}
tfs := templatedFS{fs: source, remarkURL: "https://remark.example.com"}
tbl := []struct {
name string
want string
}{
{"iframe.html", "host: 'https://remark.example.com'"},
{"embed.mjs", "host: 'https://remark.example.com'"},
{"embed.js", "host: 'https://remark.example.com'"},
// the docker image rewrites html, js and mjs and nothing else, and a stylesheet carrying
// the marker would mean the frontend started templating a file type this does not cover
{"remark.css", placeholder},
{"nothing.html", "no marker here"},
}
for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
f, err := tfs.Open(tt.name)
require.NoError(t, err)
defer func() { assert.NoError(t, f.Close()) }()
body, err := io.ReadAll(f)
require.NoError(t, err)
assert.Equal(t, tt.want, string(body))
info, err := f.Stat()
require.NoError(t, err)
assert.Equal(t, int64(len(tt.want)), info.Size(),
"the size has to be the substituted one, or the response is truncated or left hanging")
assert.Equal(t, tt.name, info.Name())
})
}
}
func TestTemplatedFS_PassesErrorsThrough(t *testing.T) {
tfs := templatedFS{fs: fstest.MapFS{}, remarkURL: "https://remark.example.com"}
_, err := tfs.Open("absent.html")
assert.ErrorIs(t, err, fs.ErrNotExist)
}
// TestRest_FileServerFillsInTheInstanceURL covers the reason templatedFS exists: the binary serves
// the frontend build embedded in itself, and nothing else fills the placeholder in for it.
func TestRest_FileServerFillsInTheInstanceURL(t *testing.T) {
frontend := fstest.MapFS{
"embed.mjs": {Data: []byte("host=\"" + remarkURLPlaceholder + "\"")},
"logo.svg": {Data: []byte(remarkURLPlaceholder)},
"plain.html": {Data: []byte("nothing to fill in")},
}
router := routegroup.New(http.NewServeMux())
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", "https://remark.example.com")
ts := httptest.NewServer(router)
defer ts.Close()
t.Run("the bundle carries the configured url", func(t *testing.T) {
body, code := get(t, ts.URL+"/web/embed.mjs")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, `host="https://remark.example.com"`, body)
})
t.Run("the legacy js name carries it too", func(t *testing.T) {
body, code := get(t, ts.URL+"/web/embed.js")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, `host="https://remark.example.com"`, body)
})
t.Run("other types are served untouched", func(t *testing.T) {
body, code := get(t, ts.URL+"/web/logo.svg")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, remarkURLPlaceholder, body)
})
t.Run("a file without the marker is unchanged", func(t *testing.T) {
body, code := get(t, ts.URL+"/web/plain.html")
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, "nothing to fill in", body)
})
}
// TestRest_FileServerEtagVariesWithTheInstanceURL covers the case this substitution exists for. An
// operator who notices the widget is addressed to the wrong host corrects REMARK_URL and restarts,
// and the binary and so the version is unchanged. If the validator ignores remarkURL the client
// revalidates, gets 304 and keeps the bundle pointing at the old host. Cache-Control is no-cache,
// so it revalidates every time and never ages out of that state.
func TestRest_FileServerEtagVariesWithTheInstanceURL(t *testing.T) {
frontend := fstest.MapFS{"embed.mjs": {Data: []byte("host=\"" + remarkURLPlaceholder + "\"")}}
etagFor := func(remarkURL string) string {
router := routegroup.New(http.NewServeMux())
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", remarkURL)
ts := httptest.NewServer(router)
defer ts.Close()
resp, err := http.Get(ts.URL + "/web/embed.mjs")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
return resp.Header.Get("Etag")
}
first := etagFor("https://old.example.com")
second := etagFor("https://new.example.com")
require.NotEmpty(t, first, "the file server has to send a validator at all")
assert.NotEqual(t, first, second,
"same version and same path, different instance url: the validator has to change or the "+
"client keeps a bundle addressed to the old host")
}
+2 -2
View File
@@ -813,8 +813,8 @@ func imgHTTPTestsServer(t *testing.T) *httptest.Server {
return return
} }
if r.URL.Path == "/image/img-slow.png" { if r.URL.Path == "/image/img-slow.png" {
time.Sleep(500 * time.Millisecond) // hold the response until the proxy gives up on its own timeout
w.WriteHeader(500) <-r.Context().Done()
return return
} }
t.Log("http img request - not found", r.URL) t.Log("http img request - not found", r.URL)
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"regexp" "regexp"
"slices"
"strings" "strings"
"time" "time"
@@ -151,8 +152,8 @@ func (c *Comment) Snippet(limit int) string {
} }
snippet := []rune(cleanText)[:limit] snippet := []rune(cleanText)[:limit]
// go back in snippet and found the first space // go back in snippet and found the first space
for i := len(snippet) - 1; i >= 0; i-- { for i, s := range slices.Backward(snippet) {
if snippet[i] == ' ' { if s == ' ' {
snippet = snippet[:i] snippet = snippet[:i]
break break
} }
+12 -16
View File
@@ -3,13 +3,14 @@ package engine
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"github.com/hashicorp/go-multierror"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
berrors "go.etcd.io/bbolt/errors"
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
) )
@@ -413,14 +414,14 @@ func (b *BoltDB) Delete(req DeleteRequest) error {
// Close boltdb store // Close boltdb store
func (b *BoltDB) Close() error { func (b *BoltDB) Close() error {
errs := new(multierror.Error) var errs []error
for site, db := range b.dbs { for site, db := range b.dbs {
err := db.Close() err := db.Close()
if err != nil { if err != nil {
errs = multierror.Append(errs, fmt.Errorf("can't close site %s: %w", site, err)) errs = append(errs, fmt.Errorf("can't close site %s: %w", site, err))
} }
} }
return errs.ErrorOrNil() return errors.Join(errs...)
} }
// Last returns up to max last comments for given siteID // Last returns up to max last comments for given siteID
@@ -893,31 +894,26 @@ func (b *BoltDB) deleteUser(bdb *bolt.DB, siteID, userID string, mode store.Dele
// delete collected comments // delete collected comments
for _, ci := range comments { for _, ci := range comments {
if e := b.deleteComment(bdb, ci.locator, ci.commentID, mode); e != nil { if e := b.deleteComment(bdb, ci.locator, ci.commentID, mode); e != nil {
return fmt.Errorf("failed to delete comment %+v: %w", ci, err) return fmt.Errorf("failed to delete comment %+v: %w", ci, e)
} }
} }
// delete user bucket in hard mode // delete the user's bucket in hard mode. A user who only logged in but never commented has
// no per-user bucket, so tolerate ErrBucketNotFound; the top-level users bucket is created
// by NewBoltDB and is always present.
if mode == store.HardDelete { if mode == store.HardDelete {
err = bdb.Update(func(tx *bolt.Tx) error { err = bdb.Update(func(tx *bolt.Tx) error {
usersBkt := tx.Bucket([]byte(userBucketName)) usersBkt := tx.Bucket([]byte(userBucketName))
if usersBkt != nil { if e := usersBkt.DeleteBucket([]byte(userID)); e != nil && !errors.Is(e, berrors.ErrBucketNotFound) {
if e := usersBkt.DeleteBucket([]byte(userID)); e != nil { return fmt.Errorf("failed to delete user bucket for %s: %w", userID, e)
return fmt.Errorf("failed to delete user bucket for %s: %w", userID, err)
}
} }
return nil return nil
}) })
if err != nil { if err != nil {
return fmt.Errorf("can't delete user meta: %w", err) return fmt.Errorf("can't delete user meta: %w", err)
} }
} }
if len(comments) == 0 {
return fmt.Errorf("unknown user %s", userID)
}
return b.deleteUserDetail(bdb, userID, AllUserDetails) return b.deleteUserDetail(bdb, userID, AllUserDetails)
} }
@@ -1022,7 +1018,7 @@ func (b *BoltDB) db(siteID string) (*bolt.DB, error) {
if res, ok := b.dbs[siteID]; ok { if res, ok := b.dbs[siteID]; ok {
return res, nil return res, nil
} }
return nil, fmt.Errorf("site %q not found", siteID) return nil, fmt.Errorf("site %q %w", siteID, ErrSiteNotFound)
} }
// makeRef creates reference combining url and comment id // makeRef creates reference combining url and comment id
+57
View File
@@ -843,6 +843,63 @@ func TestBoltAdmin_DeleteUserHard(t *testing.T) {
assert.EqualError(t, err, `site "radio-t-bad" not found`) assert.EqualError(t, err, `site "radio-t-bad" not found`)
} }
// TestBoltAdmin_DeleteUserHard_NoComments covers hard-deleting a user who has no comments
// (and therefore no user bucket) — e.g. one who only logged in. This must succeed rather than
// fail on the missing bucket, and must still remove any stored user details.
func TestBoltAdmin_DeleteUserHard_NoComments(t *testing.T) {
b, teardown := prep(t)
defer teardown()
t.Run("login-only user with a detail but no comments", func(t *testing.T) {
const userID = "login-only-user"
loc := store.Locator{SiteID: "radio-t"}
// user logged in and has a stored detail, but never commented (no user bucket)
_, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail, Update: "user@example.com"})
require.NoError(t, err)
err = b.Delete(DeleteRequest{Locator: loc, UserID: userID, DeleteMode: store.HardDelete})
require.NoError(t, err, "hard delete must not fail on a missing user bucket")
details, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail})
require.NoError(t, err)
assert.Empty(t, details, "stored user detail must be removed on hard delete")
})
t.Run("unknown user is a no-op", func(t *testing.T) {
err := b.Delete(DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "never-seen-user", DeleteMode: store.HardDelete})
assert.NoError(t, err, "hard-deleting an unknown user must not error")
})
}
// TestBoltAdmin_DeleteUserSoft_NoComments covers soft-deleting a user with no comments. As with the
// hard path (and the existing soft path for users who do have comments) it cleans stored user
// details and is a no-op for an unknown user.
func TestBoltAdmin_DeleteUserSoft_NoComments(t *testing.T) {
b, teardown := prep(t)
defer teardown()
t.Run("login-only user with a detail but no comments", func(t *testing.T) {
const userID = "login-only-soft"
loc := store.Locator{SiteID: "radio-t"}
_, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail, Update: "user@example.com"})
require.NoError(t, err)
err = b.Delete(DeleteRequest{Locator: loc, UserID: userID, DeleteMode: store.SoftDelete})
require.NoError(t, err)
details, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail})
require.NoError(t, err)
assert.Empty(t, details, "soft delete cleans stored user details, consistent with the has-comments path")
})
t.Run("unknown user is a no-op", func(t *testing.T) {
err := b.Delete(DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "never-seen-soft", DeleteMode: store.SoftDelete})
assert.NoError(t, err, "soft-deleting an unknown user must not error")
})
}
func TestBoltAdmin_DeleteUserSoft(t *testing.T) { func TestBoltAdmin_DeleteUserSoft(t *testing.T) {
b, teardown := prep(t) b, teardown := prep(t)
defer teardown() defer teardown()
+5
View File
@@ -4,6 +4,7 @@ package engine
// Includes default implementation with boltdb // Includes default implementation with boltdb
import ( import (
"errors"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -11,6 +12,10 @@ import (
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
) )
// ErrSiteNotFound is returned by engines when the requested site does not exist.
// Its message is "not found" so wrapping it as `site %q %w` reads "site \"x\" not found".
var ErrSiteNotFound = errors.New("not found")
// NOTE: matryer/moq should be installed globally and works with `go generate ./...` // NOTE: matryer/moq should be installed globally and works with `go generate ./...`
//go:generate moq --out engine_mock.go . Interface //go:generate moq --out engine_mock.go . Interface
+2 -2
View File
@@ -147,8 +147,8 @@ func (f *FileSystem) Cleanup(_ context.Context, ttl time.Duration) error {
age := time.Since(info.ModTime()) age := time.Since(info.ModTime())
if age > (ttl + 100*time.Millisecond) { // delay cleanup triggering to allow commit if age > (ttl + 100*time.Millisecond) { // delay cleanup triggering to allow commit
log.Printf("[INFO] remove staging image %s, age %v", fpath, age) log.Printf("[INFO] remove staging image %s, age %v", fpath, age)
rmErr := os.Remove(fpath) //nolint:gosec // staging dir is server-only, no untrusted symlinks land here rmErr := os.Remove(fpath) //nolint:gosec // staging dir is server-only, no untrusted symlinks land here
_ = os.Remove(path.Dir(fpath)) //nolint:gosec // same staging dir _ = os.Remove(path.Dir(fpath)) //nolint:gosec // same staging dir
return rmErr return rmErr
} }
return nil return nil
+16 -6
View File
@@ -215,15 +215,24 @@ func TestFsStore_Cleanup(t *testing.T) {
return img return img
} }
// age is read from the file's modification time, so every file gets its mtime stamped right
// before each call: far past the ttl for the ones meant to go, at now for the ones meant to
// survive, leaving no window for a stalled runner to age a survivor into the wrong bucket
const ttl = 300 * time.Millisecond
age := func(file string, d time.Duration) {
mtime := time.Now().Add(-d)
require.NoError(t, os.Chtimes(file, mtime, mtime))
}
// save 3 images to staging // save 3 images to staging
img1 := save("blah_ff1.png", "user1") img1 := save("blah_ff1.png", "user1")
time.Sleep(100 * time.Millisecond)
img2 := save("blah_ff2.png", "user1") img2 := save("blah_ff2.png", "user1")
time.Sleep(100 * time.Millisecond)
img3 := save("blah_ff3.png", "user2") img3 := save("blah_ff3.png", "user2")
time.Sleep(200 * time.Millisecond) // make first image expired age(img1, time.Hour) // past the ttl, collected
err := svc.Cleanup(context.Background(), time.Millisecond*300) age(img2, 0) // fresh, survives
age(img3, 0)
err := svc.Cleanup(context.Background(), ttl)
assert.NoError(t, err) assert.NoError(t, err)
_, err = os.Stat(img1) _, err = os.Stat(img1)
@@ -242,10 +251,11 @@ func TestFsStore_Cleanup(t *testing.T) {
_, err = os.Stat(img3) _, err = os.Stat(img3)
assert.NoError(t, err, "file on staging") assert.NoError(t, err, "file on staging")
time.Sleep(200 * time.Millisecond) // make all images expired age(img2, time.Hour)
age(img3, time.Hour)
err = svc.ResetCleanupTimer("user2/blah_ff3.png") // reset the time to cleanup for third image err = svc.ResetCleanupTimer("user2/blah_ff3.png") // reset the time to cleanup for third image
assert.NoError(t, err) assert.NoError(t, err)
err = svc.Cleanup(context.Background(), time.Millisecond*300) err = svc.Cleanup(context.Background(), ttl)
assert.NoError(t, err) assert.NoError(t, err)
_, err = os.Stat(img2) _, err = os.Stat(img2)
+11 -11
View File
@@ -11,6 +11,7 @@ import (
"context" "context"
"crypto/sha1" //nolint:gosec // not used for cryptography "crypto/sha1" //nolint:gosec // not used for cryptography
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"image" "image"
_ "image/gif" // register gif decoder _ "image/gif" // register gif decoder
@@ -27,7 +28,6 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"github.com/hashicorp/go-multierror"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/image/draw" "golang.org/x/image/draw"
_ "golang.org/x/image/webp" // register webp decoder so DecodeConfig accepts what readAndValidateImage allows _ "golang.org/x/image/webp" // register webp decoder so DecodeConfig accepts what readAndValidateImage allows
@@ -43,8 +43,8 @@ type Service struct {
wg sync.WaitGroup wg sync.WaitGroup
submitCh chan submitReq submitCh chan submitReq
once sync.Once once sync.Once
term int32 // term value used atomically to detect emergency termination term atomic.Int32 // term value used atomically to detect emergency termination
submitCount int32 // atomic increment for counting submitted images submitCount atomic.Int32 // atomic increment for counting submitted images
} }
// ServiceParams contains externally adjustable parameters of Service // ServiceParams contains externally adjustable parameters of Service
@@ -91,14 +91,14 @@ func NewService(s Store, p ServiceParams) *Service {
// Commit multiple ids immediately // Commit multiple ids immediately
func (s *Service) Commit(idsFn func() []string) error { func (s *Service) Commit(idsFn func() []string) error {
errs := new(multierror.Error) var errs []error
for _, id := range idsFn() { for _, id := range idsFn() {
err := s.store.Commit(id) err := s.store.Commit(id)
if err != nil { if err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to commit image %s: %w", id, err)) errs = append(errs, fmt.Errorf("failed to commit image %s: %w", id, err))
} }
} }
return errs.ErrorOrNil() return errors.Join(errs...)
} }
// Submit multiple ids via function for delayed commit // Submit multiple ids via function for delayed commit
@@ -113,7 +113,7 @@ func (s *Service) Submit(idsFn func() []string) {
s.wg.Go(func() { s.wg.Go(func() {
for req := range s.submitCh { for req := range s.submitCh {
// wait for EditDuration expiration with emergency pass on term // wait for EditDuration expiration with emergency pass on term
for atomic.LoadInt32(&s.term) == 0 && time.Since(req.TS) <= s.EditDuration { for s.term.Load() == 0 && time.Since(req.TS) <= s.EditDuration {
time.Sleep(time.Millisecond * 10) // small sleep to relive busy wait but keep reactive for term (close) time.Sleep(time.Millisecond * 10) // small sleep to relive busy wait but keep reactive for term (close)
} }
err := s.Commit(req.idsFn) err := s.Commit(req.idsFn)
@@ -121,13 +121,13 @@ func (s *Service) Submit(idsFn func() []string) {
log.Printf("[WARN] image commit error %v", err) log.Printf("[WARN] image commit error %v", err)
} }
atomic.AddInt32(&s.submitCount, -1) s.submitCount.Add(-1)
} }
log.Printf("[INFO] image submitter terminated") log.Printf("[INFO] image submitter terminated")
}) })
}) })
atomic.AddInt32(&s.submitCount, 1) s.submitCount.Add(1)
// reset cleanup timer before submitting the images // reset cleanup timer before submitting the images
// to prevent them from being cleaned up while waiting for EditDuration to expire // to prevent them from being cleaned up while waiting for EditDuration to expire
@@ -196,14 +196,14 @@ func (s *Service) Close(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
if atomic.LoadInt32(&s.submitCount) == 0 { if s.submitCount.Load() == 0 {
return return
} }
} }
} }
} }
atomic.StoreInt32(&s.term, 1) // enforce non-delayed commits for all ids left in submitCh s.term.Store(1) // enforce non-delayed commits for all ids left in submitCh
waitForTerm(ctx) waitForTerm(ctx)
if s.submitCh != nil { if s.submitCh != nil {
+20 -4
View File
@@ -41,13 +41,15 @@ func TestService_SaveAndLoad(t *testing.T) {
assert.Equal(t, "test_id", store.LoadCalls()[0].ID) assert.Equal(t, "test_id", store.LoadCalls()[0].ID)
} }
// the resized dimensions are what resize promises; the encoded length is whatever the compressor
// in the toolchain happens to produce, and pinning it fails on a go release that changes it
func TestService_Resize(t *testing.T) { func TestService_Resize(t *testing.T) {
img, err := readAndValidateImage(gopherPNG(), 1500) img, err := readAndValidateImage(gopherPNG(), 1500)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 1462, len(img)) assert.NotEmpty(t, img)
img = resize(img, 32, 32) img = resize(img, 32, 32)
assert.Equal(t, 1135, len(img)) assertImageFits(t, img, 32, 32)
} }
func TestService_ResizeJpeg(t *testing.T) { func TestService_ResizeJpeg(t *testing.T) {
@@ -57,10 +59,24 @@ func TestService_ResizeJpeg(t *testing.T) {
img, err := readAndValidateImage(fh, 32000) img, err := readAndValidateImage(fh, 32000)
assert.NoError(t, err) assert.NoError(t, err)
assert.InDelta(t, 16756, len(img), 100) assert.NotEmpty(t, img)
img = resize(img, 400, 300) img = resize(img, 400, 300)
assert.InDelta(t, 10913, len(img), 100) assertImageFits(t, img, 400, 300)
}
// assertImageFits decodes the image and checks it is inside the box resize was given, and that it
// touches one side of it, which is what fitting to a box rather than merely shrinking means
func assertImageFits(t *testing.T, data []byte, limitW, limitH int) {
t.Helper()
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
require.NoError(t, err, "the resized image does not decode")
assert.LessOrEqual(t, cfg.Width, limitW, "wider than the box it was resized into")
assert.LessOrEqual(t, cfg.Height, limitH, "taller than the box it was resized into")
assert.True(t, cfg.Width == limitW || cfg.Height == limitH,
"%dx%d touches neither side of the %dx%d box, so it was not fitted to it", cfg.Width, cfg.Height, limitW, limitH)
} }
func TestService_SaveTooLarge(t *testing.T) { func TestService_SaveTooLarge(t *testing.T) {
+72 -23
View File
@@ -3,6 +3,7 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"math" "math"
"slices" "slices"
@@ -14,7 +15,6 @@ import (
"github.com/go-pkgz/lcw/v2" "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hashicorp/go-multierror"
bf "github.com/russross/blackfriday/v2" bf "github.com/russross/blackfriday/v2"
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
@@ -128,6 +128,7 @@ func (s *DataStore) FindSince(locator store.Locator, sortMethod string, user sto
} }
changedSort := false changedSort := false
flags := s.newUserFlagCache()
// sets votes controversy for comments added prior to #274 // sets votes controversy for comments added prior to #274
// also sanitizes locator.URL for comments added prior to #927 // also sanitizes locator.URL for comments added prior to #927
for i, c := range comments { for i, c := range comments {
@@ -137,7 +138,7 @@ func (s *DataStore) FindSince(locator store.Locator, sortMethod string, user sto
changedSort = true changedSort = true
} }
} }
comments[i] = s.alterComment(c, user) comments[i] = s.alterCommentCached(c, user, flags)
} }
// resort commits if altered // resort commits if altered
@@ -249,18 +250,18 @@ func (s *DataStore) ResubmitStagingImages(sites []string) error {
if ts.IsZero() { if ts.IsZero() {
return nil return nil
} }
result := new(multierror.Error) var errs []error
for _, site := range sites { for _, site := range sites {
locator := store.Locator{SiteID: site} locator := store.Locator{SiteID: site}
comments, err := s.FindSince(locator, "time", store.User{}, ts) comments, err := s.FindSince(locator, "time", store.User{}, ts)
if err != nil { if err != nil {
result = multierror.Append(result, fmt.Errorf("problem finding comments for site %s: %w", site, err)) errs = append(errs, fmt.Errorf("problem finding comments for site %s: %w", site, err))
} }
for _, c := range comments { for _, c := range comments {
s.submitImages(c) s.submitImages(c)
} }
} }
return result.ErrorOrNil() return errors.Join(errs...)
} }
// submitImages initiated delayed commit of all images from the comment uploaded to remark42 // submitImages initiated delayed commit of all images from the comment uploaded to remark42
@@ -915,32 +916,32 @@ func (s *DataStore) Metas(siteID string) (umetas []UserMetaData, pmetas []PostMe
// SetMetas saves metadata for users and posts // SetMetas saves metadata for users and posts
func (s *DataStore) SetMetas(siteID string, umetas []UserMetaData, pmetas []PostMetaData) (err error) { func (s *DataStore) SetMetas(siteID string, umetas []UserMetaData, pmetas []PostMetaData) (err error) {
errs := new(multierror.Error) var errs []error
// save posts metas // save posts metas
for _, pm := range pmetas { for _, pm := range pmetas {
if pm.ReadOnly { if pm.ReadOnly {
errs = multierror.Append(errs, s.SetReadOnly(store.Locator{SiteID: siteID, URL: pm.URL}, true)) errs = append(errs, s.SetReadOnly(store.Locator{SiteID: siteID, URL: pm.URL}, true))
} }
} }
// save users metas // save users metas
for _, um := range umetas { for _, um := range umetas {
if um.Blocked.Status { if um.Blocked.Status {
errs = multierror.Append(errs, s.SetBlock(siteID, um.ID, true, time.Until(um.Blocked.Until))) errs = append(errs, s.SetBlock(siteID, um.ID, true, time.Until(um.Blocked.Until)))
} }
if um.Verified { if um.Verified {
errs = multierror.Append(errs, s.SetVerified(siteID, um.ID, true)) errs = append(errs, s.SetVerified(siteID, um.ID, true))
} }
// this code doesn't delete user details in case they are not set in import but present in DB already // this code doesn't delete user details in case they are not set in import but present in DB already
if um.Details.Email != "" { if um.Details.Email != "" {
req := engine.UserDetailRequest{Locator: store.Locator{SiteID: siteID}, UserID: um.ID, Detail: engine.UserEmail, Update: um.Details.Email} req := engine.UserDetailRequest{Locator: store.Locator{SiteID: siteID}, UserID: um.ID, Detail: engine.UserEmail, Update: um.Details.Email}
_, err := s.Engine.UserDetail(req) _, err := s.Engine.UserDetail(req)
errs = multierror.Append(errs, err) errs = append(errs, err)
} }
} }
return errs.ErrorOrNil() return errors.Join(errs...)
} }
// User gets comment for given userID on siteID // User gets comment for given userID on siteID
@@ -972,15 +973,15 @@ func (s *DataStore) Last(siteID string, limit int, since time.Time, user store.U
// Close store service // Close store service
func (s *DataStore) Close() error { func (s *DataStore) Close() error {
errs := new(multierror.Error) var errs []error
if s.repliesCache.LoadingCache != nil { if s.repliesCache.LoadingCache != nil {
errs = multierror.Append(errs, s.repliesCache.Close()) errs = append(errs, s.repliesCache.Close())
} }
if s.TitleExtractor != nil { if s.TitleExtractor != nil {
errs = multierror.Append(errs, s.TitleExtractor.Close()) errs = append(errs, s.TitleExtractor.Close())
} }
errs = multierror.Append(errs, s.Engine.Close()) errs = append(errs, s.Engine.Close())
return errs.ErrorOrNil() return errors.Join(errs...)
} }
func (s *DataStore) upsAndDowns(c store.Comment) (ups, downs int) { func (s *DataStore) upsAndDowns(c store.Comment) (ups, downs int) {
@@ -1011,25 +1012,28 @@ func (s *DataStore) getScopedLocks(id string) (lock sync.Locker) {
func (s *DataStore) alterComments(cc []store.Comment, user store.User) (res []store.Comment) { func (s *DataStore) alterComments(cc []store.Comment, user store.User) (res []store.Comment) {
res = make([]store.Comment, len(cc)) res = make([]store.Comment, len(cc))
flags := s.newUserFlagCache()
for i, c := range cc { for i, c := range cc {
res[i] = s.alterComment(c, user) res[i] = s.alterCommentCached(c, user, flags)
} }
return res return res
} }
func (s *DataStore) alterComment(c store.Comment, user store.User) (res store.Comment) { func (s *DataStore) alterComment(c store.Comment, user store.User) (res store.Comment) {
blocReq := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: c.Locator.SiteID}, UserID: c.User.ID} return s.alterCommentCached(c, user, s.newUserFlagCache())
blocked, bErr := s.Engine.Flag(blocReq) }
// alterCommentCached is alterComment sharing a userFlagCache so that block/verified
// lookups for a user repeated across a listing hit the engine only once.
func (s *DataStore) alterCommentCached(c store.Comment, user store.User, flags *userFlagCache) (res store.Comment) {
// mark user blocked // mark user blocked
if bErr == nil && blocked { if flags.blocked(c.Locator.SiteID, c.User.ID) {
c.User.Blocked = blocked c.User.Blocked = true
} }
// set verified status retroactively // set verified status retroactively
if !c.User.Blocked { if !c.User.Blocked {
verifReq := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: c.Locator.SiteID}, UserID: c.User.ID} c.User.Verified = flags.verified(c.Locator.SiteID, c.User.ID)
c.User.Verified, _ = s.Engine.Flag(verifReq)
} }
// hide info from non-admins // hide info from non-admins
@@ -1043,6 +1047,51 @@ func (s *DataStore) alterComment(c store.Comment, user store.User) (res store.Co
return c return c
} }
// userFlagCache memoises engine block/verified flag lookups by site and user within
// a single listing, avoiding two engine.Flag calls per comment for repeated users.
type userFlagCache struct {
s *DataStore
blockedM map[flagKey]bool
verifiedM map[flagKey]bool
}
type flagKey struct {
siteID string
userID string
}
func (s *DataStore) newUserFlagCache() *userFlagCache {
return &userFlagCache{s: s, blockedM: map[flagKey]bool{}, verifiedM: map[flagKey]bool{}}
}
func (f *userFlagCache) blocked(siteID, userID string) bool {
key := flagKey{siteID: siteID, userID: userID}
if v, ok := f.blockedM[key]; ok {
return v
}
v, err := f.s.Engine.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: siteID}, UserID: userID})
if err != nil {
// don't cache on error so a repeated user is retried, matching the
// pre-refactor per-comment behavior; treat this comment as not blocked
return false
}
f.blockedM[key] = v
return v
}
func (f *userFlagCache) verified(siteID, userID string) bool {
key := flagKey{siteID: siteID, userID: userID}
if v, ok := f.verifiedM[key]; ok {
return v
}
v, err := f.s.Engine.Flag(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: siteID}, UserID: userID})
if err != nil {
return false // don't cache on error, retry on the next comment for this user
}
f.verifiedM[key] = v
return v
}
// prepare vote info for client view // prepare vote info for client view
func (s *DataStore) prepVotes(c store.Comment, user store.User) store.Comment { func (s *DataStore) prepVotes(c store.Comment, user store.User) store.Comment {
c.Vote = 0 // default is "none" (not voted) c.Vote = 0 // default is "none" (not voted)
+77 -4
View File
@@ -38,6 +38,7 @@ func TestService_CreateFromEmpty(t *testing.T) {
User: store.User{IP: "192.168.1.1", ID: "user", Name: "name"}, User: store.User{IP: "192.168.1.1", ID: "user", Name: "name"},
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
} }
beforeCreate := time.Now()
id, err := b.Create(comment) id, err := b.Create(comment)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, id != "", id) assert.True(t, id != "", id)
@@ -46,7 +47,7 @@ func TestService_CreateFromEmpty(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
t.Logf("%+v", res) t.Logf("%+v", res)
assert.Equal(t, "text", res.Text) assert.Equal(t, "text", res.Text)
assert.True(t, time.Since(res.Timestamp).Seconds() < 1) assert.WithinRange(t, res.Timestamp, beforeCreate, time.Now(), "timestamp set during create")
assert.Equal(t, "user", res.User.ID) assert.Equal(t, "user", res.User.ID)
assert.Equal(t, "name", res.User.Name) assert.Equal(t, "name", res.User.Name)
assert.Equal(t, "23f97cf4d5c29ef788ca2bdd1c9e75656c0e4149", res.User.IP) assert.Equal(t, "23f97cf4d5c29ef788ca2bdd1c9e75656c0e4149", res.User.IP)
@@ -218,9 +219,9 @@ func TestService_Put(t *testing.T) {
} }
func TestService_SetTitle(t *testing.T) { func TestService_SetTitle(t *testing.T) {
var titleEnable int32 var titleEnable atomic.Int32
tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.LoadInt32(&titleEnable) == 0 { if titleEnable.Load() == 0 {
w.WriteHeader(404) w.WriteHeader(404)
} }
if r.URL.String() == "/post1" { if r.URL.String() == "/post1" {
@@ -262,7 +263,7 @@ func TestService_SetTitle(t *testing.T) {
b.TitleExtractor.cache.Purge() b.TitleExtractor.cache.Purge()
atomic.StoreInt32(&titleEnable, 1) titleEnable.Store(1)
c, err := b.SetTitle(store.Locator{URL: tss.URL + "/post1", SiteID: "radio-t"}, id) c, err := b.SetTitle(store.Locator{URL: tss.URL + "/post1", SiteID: "radio-t"}, id)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "post1 blah 123", c.PostTitle) assert.Equal(t, "post1 blah 123", c.PostTitle)
@@ -1878,6 +1879,78 @@ func TestService_alterComment(t *testing.T) {
assert.Equal(t, engine.FlagRequest{Flag: engine.Blocked, UserID: "devid"}, engineMock.FlagCalls()[0].Req) assert.Equal(t, engine.FlagRequest{Flag: engine.Blocked, UserID: "devid"}, engineMock.FlagCalls()[0].Req)
} }
func TestService_alterCommentsFlagCaching(t *testing.T) {
t.Run("repeated user looked up once", func(t *testing.T) {
engineMock := engine.InterfaceMock{
FlagFunc: func(engine.FlagRequest) (bool, error) { return false, nil },
}
svc := DataStore{Engine: &engineMock}
comments := make([]store.Comment, 0, 5)
for i := range 5 {
comments = append(comments, store.Comment{ID: fmt.Sprintf("c%d", i),
User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}})
}
svc.alterComments(comments, store.User{ID: "u1"})
// one Blocked + one Verified lookup for the single user, not two per comment
assert.Equal(t, 2, len(engineMock.FlagCalls()), "5 comments by one user -> 2 flag lookups")
})
t.Run("distinct users looked up per user", func(t *testing.T) {
engineMock := engine.InterfaceMock{
FlagFunc: func(req engine.FlagRequest) (bool, error) {
return req.Flag == engine.Blocked && req.UserID == "blocked", nil // "blocked" user is blocked
},
}
svc := DataStore{Engine: &engineMock}
comments := []store.Comment{
{ID: "c1", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
{ID: "c2", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
{ID: "c3", User: store.User{ID: "blocked"}, Locator: store.Locator{SiteID: "site1"}},
{ID: "c4", User: store.User{ID: "blocked"}, Locator: store.Locator{SiteID: "site1"}},
}
res := svc.alterComments(comments, store.User{ID: "admin", Admin: true})
// u1: Blocked+Verified (2); blocked user: Blocked only, Verified skipped (1) = 3 total
assert.Equal(t, 3, len(engineMock.FlagCalls()), "two distinct users -> 3 flag lookups")
assert.True(t, res[2].User.Blocked && res[3].User.Blocked, "blocked user marked blocked")
assert.False(t, res[0].User.Blocked, "u1 not blocked")
})
t.Run("flag read error is not cached", func(t *testing.T) {
var blockedCalls int
engineMock := engine.InterfaceMock{
FlagFunc: func(req engine.FlagRequest) (bool, error) {
if req.Flag == engine.Blocked {
blockedCalls++
if blockedCalls == 1 {
return false, fmt.Errorf("transient flag read error")
}
return true, nil
}
return false, nil
},
}
svc := DataStore{Engine: &engineMock}
comments := []store.Comment{
{ID: "c0", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
{ID: "c1", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
{ID: "c2", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
}
res := svc.alterComments(comments, store.User{ID: "admin", Admin: true})
// the errored first lookup must not be cached, so the next comment retries and
// picks up the real blocked state; once it succeeds the result is cached
assert.False(t, res[0].User.Blocked, "errored lookup treated as not blocked")
assert.True(t, res[1].User.Blocked, "retry after error picks up blocked state")
assert.True(t, res[2].User.Blocked, "successful read is cached")
assert.Equal(t, 2, blockedCalls, "blocked retried once after the error, then cached")
})
}
func Benchmark_ServiceCreate(b *testing.B) { func Benchmark_ServiceCreate(b *testing.B) {
dbFile := fmt.Sprintf("%s/test-remark42-%d.db", os.TempDir(), rand.Intn(9999999999)) dbFile := fmt.Sprintf("%s/test-remark42-%d.db", os.TempDir(), rand.Intn(9999999999))
defer func() { _ = os.Remove(dbFile) }() defer func() { _ = os.Remove(dbFile) }()
+9 -9
View File
@@ -44,10 +44,10 @@ func TestTitle_GetTitle(t *testing.T) {
func TestTitle_Get(t *testing.T) { func TestTitle_Get(t *testing.T) {
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"}) ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
defer ex.Close() defer ex.Close()
var hits int32 var hits atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/good" { if r.URL.String() == "/good" {
atomic.AddInt32(&hits, 1) hits.Add(1)
_, err := w.Write([]byte("<html><title>\n\n blah 123\n</title><body> 2222</body></html>")) _, err := w.Write([]byte("<html><title>\n\n blah 123\n</title><body> 2222</body></html>"))
assert.NoError(t, err) assert.NoError(t, err)
return return
@@ -68,7 +68,7 @@ func TestTitle_Get(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "blah 123", r) assert.Equal(t, "blah 123", r)
} }
assert.Equal(t, int32(1), atomic.LoadInt32(&hits)) assert.Equal(t, int32(1), hits.Load())
} }
func TestTitle_GetConcurrent(t *testing.T) { func TestTitle_GetConcurrent(t *testing.T) {
@@ -78,10 +78,10 @@ func TestTitle_GetConcurrent(t *testing.T) {
} }
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"}) ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
defer ex.Close() defer ex.Close()
var hits int32 var hits atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.String(), "/good") { if strings.HasPrefix(r.URL.String(), "/good") {
atomic.AddInt32(&hits, 1) hits.Add(1)
_, err := fmt.Fprintf(w, "<html><title>blah 123 %s</title><body>%s</body></html>", r.URL.String(), body.String()) _, err := fmt.Fprintf(w, "<html><title>blah 123 %s</title><body>%s</body></html>", r.URL.String(), body.String())
assert.NoError(t, err) assert.NoError(t, err)
return return
@@ -100,15 +100,15 @@ func TestTitle_GetConcurrent(t *testing.T) {
}) })
} }
g.Wait() g.Wait()
assert.Equal(t, int32(100), atomic.LoadInt32(&hits)) assert.Equal(t, int32(100), hits.Load())
} }
func TestTitle_GetFailed(t *testing.T) { func TestTitle_GetFailed(t *testing.T) {
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"}) ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
defer ex.Close() defer ex.Close()
var hits int32 var hits atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1) hits.Add(1)
w.WriteHeader(404) w.WriteHeader(404)
})) }))
defer ts.Close() defer ts.Close()
@@ -121,7 +121,7 @@ func TestTitle_GetFailed(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "", r) assert.Equal(t, "", r)
} }
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "hit once, errors cached") assert.Equal(t, int32(1), hits.Load(), "hit once, errors cached")
} }
func TestTitle_DoubleClosed(t *testing.T) { func TestTitle_DoubleClosed(t *testing.T) {
+3 -3
View File
@@ -211,9 +211,9 @@ func (t *Tree) limit(limit int, offsetID string) {
continue continue
} }
// check if we just exceeded the limit and there are already some nodes in the list, // stop once adding this subtree would exceed the limit, as long as we already have a node;
// as otherwise we would have to return the first node with all its replies even if it exceeds the limit. // a subtree that fits exactly is still included, and the first node is always returned in full.
if commentsCount+repliesCount >= limit && len(limitedNodes) > 0 { if commentsCount+repliesCount > limit && len(limitedNodes) > 0 {
t.countLeft += repliesCount t.countLeft += repliesCount
commentsCount = limit // adjust commentsCount to stop checking limit for the next nodes commentsCount = limit // adjust commentsCount to stop checking limit for the next nodes
continue continue
+94
View File
@@ -152,6 +152,100 @@ func TestTreeSortNodes(t *testing.T) {
assert.Equal(t, "1", res.Nodes[0].Comment.ID) assert.Equal(t, "1", res.Nodes[0].Comment.ID)
} }
func TestMakeTreeLimit(t *testing.T) {
loc := store.Locator{URL: "url", SiteID: "site"}
ts := func(sec int) time.Time { return time.Date(2017, 12, 25, 19, 0, sec, 0, time.UTC) }
// tree with four top-level comments and subtree sizes 3, 2, 1, 3 (total 9):
// c1 -> c1a, c1b
// c2 -> c2a
// c3
// c4 -> c4a -> c4a1
comments := []store.Comment{
{Locator: loc, ID: "c1", Timestamp: ts(1)},
{Locator: loc, ID: "c1a", ParentID: "c1", Timestamp: ts(11)},
{Locator: loc, ID: "c1b", ParentID: "c1", Timestamp: ts(12)},
{Locator: loc, ID: "c2", Timestamp: ts(2)},
{Locator: loc, ID: "c2a", ParentID: "c2", Timestamp: ts(21)},
{Locator: loc, ID: "c3", Timestamp: ts(3)},
{Locator: loc, ID: "c4", Timestamp: ts(4)},
{Locator: loc, ID: "c4a", ParentID: "c4", Timestamp: ts(41)},
{Locator: loc, ID: "c4a1", ParentID: "c4a", Timestamp: ts(42)},
}
nodeIDs := func(nodes []*Node) []string {
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
ids = append(ids, n.Comment.ID)
}
return ids
}
tests := []struct {
name string
limit int
offsetID string
wantNodes []string
wantLeft int
wantLast string
}{
{"no limit, no offset returns all", 0, "", []string{"c1", "c2", "c3", "c4"}, 0, ""},
{"limit equals first subtree size", 3, "", []string{"c1"}, 6, "c1"},
{"limit smaller than first subtree returns it whole", 2, "", []string{"c1"}, 6, "c1"},
{"limit between first and second boundary stops after first", 4, "", []string{"c1"}, 6, "c1"},
{"limit at exact two-subtree boundary includes both", 5, "", []string{"c1", "c2"}, 4, "c2"},
{"limit reaches third subtree exactly", 6, "", []string{"c1", "c2", "c3"}, 3, "c3"},
{"limit equal to total returns all", 9, "", []string{"c1", "c2", "c3", "c4"}, 0, "c4"},
{"limit larger than total returns all", 100, "", []string{"c1", "c2", "c3", "c4"}, 0, "c4"},
{"offset only, no limit slices remainder", 0, "c1", []string{"c2", "c3", "c4"}, 0, ""},
{"offset at last node clears result", 0, "c4", []string{}, 0, ""},
{"offset at last node with limit clears result", 5, "c4", []string{}, 0, ""},
{"offset not found starts from beginning", 0, "missing", []string{"c1", "c2", "c3", "c4"}, 0, ""},
{"offset plus limit returns single subtree", 2, "c1", []string{"c2"}, 4, "c2"},
{"offset plus limit stops before last subtree", 3, "c2", []string{"c3"}, 3, "c3"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
res := MakeTree(comments, "+time", tc.limit, tc.offsetID)
assert.Equal(t, tc.wantNodes, nodeIDs(res.Nodes), "top-level nodes")
assert.Equal(t, tc.wantLeft, res.CountLeft(), "count left")
assert.Equal(t, tc.wantLast, res.LastComment(), "last comment")
})
}
}
func TestCountReplies(t *testing.T) {
loc := store.Locator{URL: "url", SiteID: "site"}
ts := func(sec int) time.Time { return time.Date(2017, 12, 25, 19, 0, sec, 0, time.UTC) }
comments := []store.Comment{
{Locator: loc, ID: "c1", Timestamp: ts(1)},
{Locator: loc, ID: "c1a", ParentID: "c1", Timestamp: ts(11)},
{Locator: loc, ID: "c1b", ParentID: "c1", Timestamp: ts(12)},
{Locator: loc, ID: "c4", Timestamp: ts(4)},
{Locator: loc, ID: "c4a", ParentID: "c4", Timestamp: ts(41)},
{Locator: loc, ID: "c4a1", ParentID: "c4a", Timestamp: ts(42)},
}
res := MakeTree(comments, "+time", 0, "")
byID := map[string]*Node{}
for _, n := range res.Nodes {
byID[n.Comment.ID] = n
}
// guard presence and shape first so a regression in MakeTree fails with a clear
// assertion instead of a nil-pointer panic on the map lookups below
require.Contains(t, byID, "c1")
require.Contains(t, byID, "c4")
require.Len(t, byID["c1"].Replies, 2)
require.Len(t, byID["c4"].Replies, 1)
assert.Equal(t, 2, countReplies(byID["c1"]), "c1 has two direct replies, no nesting")
assert.Equal(t, 2, countReplies(byID["c4"]), "c4 counts nested reply recursively")
assert.Equal(t, 1, countReplies(byID["c4"].Replies[0]), "c4a has one nested reply")
assert.Equal(t, 0, countReplies(byID["c1"].Replies[0]), "leaf reply has no replies")
}
func BenchmarkTree(b *testing.B) { func BenchmarkTree(b *testing.B) {
comments := []store.Comment{} comments := []store.Comment{}
data, err := os.ReadFile("testdata/tree_bench.json") data, err := os.ReadFile("testdata/tree_bench.json")

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

@@ -7,7 +7,6 @@
<meta name="description" content="" /> <meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style> <style>
/* stylelint-disable mavrin/stylelint-declaration-use-css-custom-properties */
html { html {
color: #222; color: #222;
font-size: 1em; font-size: 1em;
@@ -182,7 +181,7 @@
.visuallyhidden { .visuallyhidden {
border: 0; border: 0;
clip: rect(0 0 0 0); clip-path: inset(50%);
height: 1px; height: 1px;
margin: -1px; margin: -1px;
overflow: hidden; overflow: hidden;
@@ -193,7 +192,7 @@
.visuallyhidden.focusable:active, .visuallyhidden.focusable:active,
.visuallyhidden.focusable:focus { .visuallyhidden.focusable:focus {
clip: auto; clip-path: none;
height: auto; height: auto;
margin: 0; margin: 0;
overflow: visible; overflow: visible;
@@ -254,7 +253,7 @@
pre, pre,
blockquote { blockquote {
border: 1px solid #999; border: 1px solid #999;
page-break-inside: avoid; break-inside: avoid;
} }
thead { thead {
@@ -263,7 +262,7 @@
tr, tr,
img { img {
page-break-inside: avoid; break-inside: avoid;
} }
img { img {
@@ -279,7 +278,7 @@
h2, h2,
h3 { h3 {
page-break-after: avoid; break-after: avoid;
} }
} }
</style> </style>
@@ -367,8 +366,7 @@
|------------ | -------------| |------------ | -------------|
|Content from cell 1 | Content from cell 2| |Content from cell 1 | Content from cell 2|
|Content in the first column | Content in the second column| |Content in the first column | Content in the second column|
</pre </pre>
>
</article> </article>
<aside> <aside>
+18
View File
@@ -0,0 +1,18 @@
// Package webassets holds the files served under /web that the frontend build does not produce:
// plain pages and images with no dependency on the bundler's output, embedded into the binary.
// A file of the same name in the frontend output, on disk under --web-root or embedded at
// app/cmd/web, is served instead, which is how an operator replaces one of these.
// Email and error-page templates are a separate set and live in app/templates.
package webassets
import (
"embed"
"io/fs"
)
//go:embed assets
var embedded embed.FS
// FS holds the assets, each named by its path under /web. fs.Sub cannot fail for a constant
// valid path on an embed.FS, so the error is dropped the same way app/cmd/web's is.
var FS, _ = fs.Sub(embedded, "assets")
+78
View File
@@ -0,0 +1,78 @@
package webassets
import (
"io/fs"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFS_Contents(t *testing.T) {
entries, err := fs.ReadDir(FS, ".")
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, e := range entries {
assert.False(t, e.IsDir(), "the assets are served flat under /web, %s is a directory", e.Name())
info, err := e.Info()
require.NoError(t, err)
assert.NotZero(t, info.Size(), "%s is empty", e.Name())
names = append(names, e.Name())
}
assert.Equal(t, []string{"400x400.jpeg", "markdown-help.html", "privacy.html"}, names)
}
// TestFS_ContentShape catches an asset that has been truncated or replaced by something of the
// wrong kind, which a size check alone lets through.
func TestFS_ContentShape(t *testing.T) {
tbl := []struct {
name string
prefix []byte
want string
}{
{name: "400x400.jpeg", prefix: []byte{0xff, 0xd8, 0xff}},
{name: "markdown-help.html", want: "<!DOCTYPE html>"},
{name: "privacy.html", want: "<!DOCTYPE html>"},
}
for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
b, err := fs.ReadFile(FS, tt.name)
require.NoError(t, err)
if tt.prefix != nil {
require.GreaterOrEqual(t, len(b), len(tt.prefix))
assert.Equal(t, tt.prefix, b[:len(tt.prefix)], "not a JPEG")
return
}
assert.True(t, strings.HasPrefix(strings.TrimSpace(string(b)), tt.want), "not an HTML document")
assert.Contains(t, string(b), "</html>", "the document is truncated")
})
}
}
// TestFS_RelativeReferencesResolve keeps the pages self-contained: every relative src and href
// they use has to name a sibling that ships alongside them, since nothing else supplies one.
func TestFS_RelativeReferencesResolve(t *testing.T) {
ref := regexp.MustCompile(`(?:src|href)="([^"]+)"`)
external := regexp.MustCompile(`^(?:[a-z]+:|//|#|mailto:)`)
for _, page := range []string{"markdown-help.html", "privacy.html"} {
t.Run(page, func(t *testing.T) {
b, err := fs.ReadFile(FS, page)
require.NoError(t, err)
for _, m := range ref.FindAllStringSubmatch(string(b), -1) {
target := m[1]
if external.MatchString(target) {
continue
}
_, err := fs.Stat(FS, target)
assert.NoError(t, err, "%s references %q, which ships nowhere", page, target)
}
})
}
}
+27 -34
View File
@@ -5,69 +5,62 @@ go 1.25.0
require ( require (
github.com/Depado/bfchroma/v2 v2.0.0 github.com/Depado/bfchroma/v2 v2.0.0
github.com/PuerkitoBio/goquery v1.12.0 github.com/PuerkitoBio/goquery v1.12.0
github.com/alecthomas/chroma/v2 v2.24.1 github.com/alecthomas/chroma/v2 v2.27.0
github.com/didip/tollbooth/v8 v8.0.1 github.com/didip/tollbooth/v8 v8.0.1
github.com/go-chi/chi/v5 v5.2.5 github.com/go-pkgz/auth/v2 v2.2.0
github.com/go-chi/cors v1.2.2 github.com/go-pkgz/jrpc v0.4.2
github.com/go-pkgz/auth/v2 v2.1.4 github.com/go-pkgz/lcw/v2 v2.1.0
github.com/go-pkgz/jrpc v0.4.0 github.com/go-pkgz/lgr v0.12.4
github.com/go-pkgz/lcw/v2 v2.0.0 github.com/go-pkgz/notify v1.4.0
github.com/go-pkgz/lgr v0.12.3
github.com/go-pkgz/notify v1.3.0
github.com/go-pkgz/repeater/v2 v2.2.0 github.com/go-pkgz/repeater/v2 v2.2.0
github.com/go-pkgz/rest v1.21.0 github.com/go-pkgz/rest v1.24.0
github.com/go-pkgz/syncs v1.3.2 github.com/go-pkgz/routegroup v1.6.1
github.com/go-pkgz/syncs v1.3.3
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0 github.com/gorilla/feeds v1.2.0
github.com/hashicorp/go-multierror v1.1.1
github.com/jessevdk/go-flags v1.6.1 github.com/jessevdk/go-flags v1.6.1
github.com/kyokomi/emoji/v2 v2.2.13 github.com/kyokomi/emoji/v2 v2.2.14
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/rs/xid v1.6.0 github.com/rs/xid v1.6.0
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.1
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.5.0
go.uber.org/goleak v1.3.0 go.uber.org/goleak v1.3.0
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.55.0
golang.org/x/image v0.40.0 golang.org/x/image v0.45.0
golang.org/x/net v0.54.0 golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
) )
require ( require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dghubble/oauth1 v0.7.3 // indirect github.com/dghubble/oauth1 v0.7.3 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/go-oauth2/oauth2/v4 v4.5.4 // indirect github.com/go-oauth2/oauth2/v4 v4.5.4 // indirect
github.com/go-pkgz/email v0.6.0 // indirect github.com/go-pkgz/email v0.8.0 // indirect
github.com/go-pkgz/expirable-cache/v3 v3.1.0 // indirect github.com/go-pkgz/expirable-cache/v3 v3.1.1 // indirect
github.com/go-pkgz/repeater v1.2.0 // indirect
github.com/go-pkgz/routegroup v1.6.0 // indirect
github.com/golang/snappy v1.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/compress v1.19.2 // indirect
github.com/montanaflynn/stats v0.9.0 // indirect github.com/montanaflynn/stats v0.12.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect
github.com/slack-go/slack v0.23.1 // indirect github.com/slack-go/slack v0.29.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/sys v0.47.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect golang.org/x/text v0.41.0 // indirect
) )
+58 -118
View File
@@ -8,18 +8,16 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -34,49 +32,42 @@ github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE
github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY= github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY=
github.com/didip/tollbooth/v8 v8.0.1 h1:VAAapTo1t4Bn6bbpcHjuovwoa9u3JH++wgjbpWv+rB8= github.com/didip/tollbooth/v8 v8.0.1 h1:VAAapTo1t4Bn6bbpcHjuovwoa9u3JH++wgjbpWv+rB8=
github.com/didip/tollbooth/v8 v8.0.1/go.mod h1:oEd9l+ep373d7DmvKLc0a5gasPOev2mTewi6KPQBGJ4= github.com/didip/tollbooth/v8 v8.0.1/go.mod h1:oEd9l+ep373d7DmvKLc0a5gasPOev2mTewi6KPQBGJ4=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0= github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0=
github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ= github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ=
github.com/go-pkgz/auth/v2 v2.1.4 h1:bCF0vMscOrShF2gelcvKPgskpwQNGCk6AQcoXOf2kbE= github.com/go-pkgz/auth/v2 v2.2.0 h1:vQO+GTFDjAaBNSdcLLLr3Xibka67GZPtkRRItVVS4ow=
github.com/go-pkgz/auth/v2 v2.1.4/go.mod h1:IvxxhJIrwd1hKqFwQgBF9i+sMTmGfzAw66wmhw1zfJc= github.com/go-pkgz/auth/v2 v2.2.0/go.mod h1:iZx2JiGZ8Aef+wM0BPLMQY8aur4fLE0uyPhFRO9dYQ4=
github.com/go-pkgz/email v0.6.0 h1:snZnXldjeF4PgKSjnx9Fa25mtOgFpAOEeWvnQvrxjLE= github.com/go-pkgz/email v0.8.0 h1:6+Tgjfj7zFccFCPmURV2spKDXDb7aX/iWXBY2hBa9ww=
github.com/go-pkgz/email v0.6.0/go.mod h1:+wgi4x7S33IuCzfcCM5euN0GwQG6XvO/PBLxrNffYLI= github.com/go-pkgz/email v0.8.0/go.mod h1:+wgi4x7S33IuCzfcCM5euN0GwQG6XvO/PBLxrNffYLI=
github.com/go-pkgz/expirable-cache/v3 v3.1.0 h1:s05P851/O6QJ6Mc+7o2bh9aGtD3romB1SxDTXifdoqc= github.com/go-pkgz/expirable-cache/v3 v3.1.1 h1:ryHiSI5gBE8aJ0Jt90VFMKZxe/cosf2dZPDcUTMaHNg=
github.com/go-pkgz/expirable-cache/v3 v3.1.0/go.mod h1:6pVgNleydKPj0J2/mzrI02/RDo4ivKx5v2XlNmIjhjo= github.com/go-pkgz/expirable-cache/v3 v3.1.1/go.mod h1:peJAuIDjP76Uuc9NK55ljQlBtwwmJDvx4CnMyUcsP40=
github.com/go-pkgz/jrpc v0.4.0 h1:oD7xiGrzDkndkuCjeHGugQXxbggLSV7O1QmHhoc5pYY= github.com/go-pkgz/jrpc v0.4.2 h1:gY5mmxp9/dFd1WsHybVZILQpF11YNWWS3Ga+Pc5aIAU=
github.com/go-pkgz/jrpc v0.4.0/go.mod h1:JFoY3bRjRyx4M3CbEVDFQStMB1m2gmQ7OjqFK7q3kOo= github.com/go-pkgz/jrpc v0.4.2/go.mod h1:ZtnMpIXYmwXh6W44XO2lE5Lh5J+6KeeMIvw+vF9xXRQ=
github.com/go-pkgz/lcw/v2 v2.0.0 h1:gTwXpiJBhQeA1rXuqkRuLcV79uATFna8CckH8ZBBrH0= github.com/go-pkgz/lcw/v2 v2.1.0 h1:JAGUHRQPon658XimxIUwaqPgOPfIKa850qO8jpFJchc=
github.com/go-pkgz/lcw/v2 v2.0.0/go.mod h1:yxJHOn+IbQBQHxUqkCtMrbGjIfdYcsBAZcVCBaL1Va8= github.com/go-pkgz/lcw/v2 v2.1.0/go.mod h1:UUo4cgD6oTPooBuUslVaWqOYZAGnh/91SoaB5DBWC8s=
github.com/go-pkgz/lgr v0.12.3 h1:QDug7kRkEsuQtruT9fNF5PVT2kZUqCDPc4GmsgS3fP8= github.com/go-pkgz/lgr v0.12.4 h1:lDeQ4BR28ldXrKau6BOjq7A8nHzcXz+MF4xUfV4l1Ok=
github.com/go-pkgz/lgr v0.12.3/go.mod h1:lpCDgVvCIxBHZp8+sGCj9MPctIzKZyZ3QdE19ddqd54= github.com/go-pkgz/lgr v0.12.4/go.mod h1:Lw6DkNRnCPyX07mqkiUK/p+eA1opq4GKkWfWia64RA8=
github.com/go-pkgz/notify v1.3.0 h1:YxF/ThEoCetdcoghWdyeqaBpCkZ8mvyve7HXbCAOzYU= github.com/go-pkgz/notify v1.4.0 h1:4pP7UGdYqFO7e7V3OsQStYF006CO0cCh1ahdawt6l18=
github.com/go-pkgz/notify v1.3.0/go.mod h1:qdfi5OsViKlIFPryIOaINHTOtS9GFhOYXPqJmAMlaGU= github.com/go-pkgz/notify v1.4.0/go.mod h1:UFpL9ZvCYnLBEjeay++3afh8GceZ6qT8wj5lqfSR9U4=
github.com/go-pkgz/repeater v1.2.0 h1:oJFvjyKdTDd5RCzpzxlzYIZFFj6Zfl17rE1aUfu6UjQ=
github.com/go-pkgz/repeater v1.2.0/go.mod h1:vypP6xamA53MFmafnGUucqOmALKk36xgKu2hSG73LHM=
github.com/go-pkgz/repeater/v2 v2.2.0 h1:8nZR/NaknmLfx2YMHbr78u9OL4Xj+8+romm9dz4FpMg= github.com/go-pkgz/repeater/v2 v2.2.0 h1:8nZR/NaknmLfx2YMHbr78u9OL4Xj+8+romm9dz4FpMg=
github.com/go-pkgz/repeater/v2 v2.2.0/go.mod h1:RgX5vUbLKq7PV82QUDP5pFbQS1os4Z+U9XzKymK23A8= github.com/go-pkgz/repeater/v2 v2.2.0/go.mod h1:RgX5vUbLKq7PV82QUDP5pFbQS1os4Z+U9XzKymK23A8=
github.com/go-pkgz/rest v1.21.0 h1:Y/C4d/TpclJJDxqnH1RAcS6Hmox0RIReAlkwMcUWXK4= github.com/go-pkgz/rest v1.24.0 h1:GAUCgx7U8xCOC2OynLjhCRMhtnMQH4d1mTdKpQyX2yI=
github.com/go-pkgz/rest v1.21.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE= github.com/go-pkgz/rest v1.24.0/go.mod h1:dl3EWiuFB4hRTo2Sknj6UrQGFRAYvANK6/NyW8qQPxc=
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4= github.com/go-pkgz/routegroup v1.6.1 h1:6I/0LabazpZsHAI+jYPeyH/KU2cvZF0bFylUScMNi+Q=
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-pkgz/routegroup v1.6.1/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-pkgz/syncs v1.3.2 h1:gmioASlJNy3gNosPlgvWOM2QP0Hdjzn2u+/sUShgd8E= github.com/go-pkgz/syncs v1.3.3 h1:fFRK+eqCIFddxEiDi6ob5oQh3/NuNkjuwWQJzZ0b9lU=
github.com/go-pkgz/syncs v1.3.2/go.mod h1:qjgzpp7OpuhDf7BWsW/FHCu9DLjE32NPy6/vXAXT/Cw= github.com/go-pkgz/syncs v1.3.3/go.mod h1:lAp+w+qbRm6+pwINcfc9BVK+4F4NEoERYditsF1uENA=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
@@ -91,11 +82,6 @@ github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -106,26 +92,24 @@ github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bB
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ= github.com/montanaflynn/stats v0.12.4 h1:amtNRsti20yIhcrkfUJGwoYqBR82jKQFE8SNNYVgGn0=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/montanaflynn/stats v0.12.4/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d h1:l3+2LWCbVxn5itfvXAfH9n4YL9jh8l1g5zcncbIc1cs= github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d h1:l3+2LWCbVxn5itfvXAfH9n4YL9jh8l1g5zcncbIc1cs=
@@ -138,20 +122,20 @@ github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80= github.com/slack-go/slack v0.29.0 h1:ohhMNgp9DmPKiLhH/pNZV4NxhOXKgNy0SH8FzVHNerI=
github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs= github.com/slack-go/slack v0.29.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg= github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
@@ -191,94 +175,50 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+111 -78
View File
@@ -1,89 +1,122 @@
version: "2"
run: run:
tests: true tests: true
output: output:
print-issued-lines: false show-stats: false
formats:
text:
print-issued-lines: false
colors: true
linters: linters:
enable-all: true default: all
disable: disable:
- lll
- gocyclo
- dupl
- gochecknoglobals
- funlen
- godox
- wsl
- gocognit
- nolintlint
- testpackage
- godot
- nestif
- paralleltest
- nlreturn
- cyclop
- gci
- gofumpt
- errorlint
- exhaustive - exhaustive
- wrapcheck - prealloc
- stylecheck - dupl
- thelper - godoclint
- nonamedreturns - cyclop
- revive
- dupword
- exhaustruct
- varnamelen
- forcetypeassert
- ireturn
- maintidx
- govet
- testableexamples
- musttag
- depguard - depguard
- goconst - dupword
- perfsprint - err113
- errname
- errorlint
- exhaustruct
- forbidigo
- forcetypeassert
- funlen
- gochecknoglobals
- gocognit
- gocritic
- gocyclo
- godot
- godox
- gomoddirectives
- ireturn
- lll
- maintidx
- mnd - mnd
- nakedret
- nestif
- nilnil
- nlreturn
- nolintlint
- nonamedreturns
- paralleltest
- perfsprint
- predeclared - predeclared
- recvcheck - recvcheck
- tenv - revive
- err113 - testpackage
- varnamelen
linters-settings: - wastedassign
gocyclo: - whitespace
min-complexity: 10 - wsl
dupl: - wsl_v5
threshold: 100 - funcorder
goconst: - noinlineerr
min-len: 8 - tagalign
min-occurrences: 3 - goconst
forbidigo: - gochecknoinits
#forbid: - durationcheck
# - (Must)?NewLexer$ - embeddedstructfieldcheck
exclude_godoc_examples: false - wrapcheck
- gomodguard
settings:
dupl:
threshold: 100
exhaustive:
default-signifies-exhaustive: true
goconst:
min-len: 8
min-occurrences: 3
gocyclo:
min-complexity: 10
wrapcheck:
report-internal-errors: false
ignore-package-globs:
- github.com/alecthomas/errors
exclusions:
generated: lax
rules:
- path: (.+)\.go$
text: "^(G104|G204|G307|G304):"
- path: (.+)\.go$
text: Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked
- path: (.+)\.go$
text: exported method `(.*\.MarshalJSON|.*\.UnmarshalJSON|.*\.EntityURN|.*\.GoString|.*\.Pos)` should have comment or be unexported
- path: (.+)\.go$
text: uses unkeyed fields
- path: (.+)\.go$
text: declaration of "err" shadows declaration
- path: (.+)\.go$
text: bad syntax for struct tag key
- path: (.+)\.go$
text: bad syntax for struct tag pair
- path: (.+)\.go$
text: ^ST1012
- path: (.+)\.go$
text: log/slog.Logger.*must not be called
- path: (.+)_test\.go$
text: error returned from external package is unwrapped
- linters: [staticcheck]
text: QF1008
- text: "Error return value of `.*.Write` is not checked"
linters: [errcheck]
path: (.+)_test\.go$
paths:
- third_party$
- builtin$
- examples$
issues: issues:
exclude-dirs: max-issues-per-linter: 0
- _examples max-same-issues: 0
max-per-linter: 0 formatters:
max-same: 0 enable:
exclude-use-default: false - gofmt
exclude: - goimports
# Captured by errcheck. exclusions:
- '^(G104|G204):' generated: lax
# Very commonly not checked. paths:
- 'Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked' - third_party$
- 'exported method (.*\.MarshalJSON|.*\.UnmarshalJSON|.*\.EntityURN|.*\.GoString|.*\.Pos) should have comment or be unexported' - builtin$
- 'composite literal uses unkeyed fields' - examples$
- 'declaration of "err" shadows declaration'
- 'should not use dot imports'
- 'Potential file inclusion via variable'
- 'should have comment or be unexported'
- 'comment on exported var .* should be of the form'
- 'at least one file in a package should have a package comment'
- 'string literal contains the Unicode'
- 'methods on the same type should have the same receiver name'
- '_TokenType_name should be _TokenTypeName'
- '`_TokenType_map` should be `_TokenTypeMap`'
- 'rewrite if-else to switch statement'
+1 -1
View File
@@ -1,6 +1,6 @@
Chroma is a syntax highlighting library, tool and web playground for Go. It is based on Pygments and includes importers for it, so most of the same concepts from Pygments apply to Chroma. Chroma is a syntax highlighting library, tool and web playground for Go. It is based on Pygments and includes importers for it, so most of the same concepts from Pygments apply to Chroma.
This project is written in Go, uses Hermit to manage tooling, and Just for helper commands. Helper scripts are in ./scripts. This project is written in Go, uses Hermit to manage tooling, and Just for helper commands. Helper tooling is primarily in ./_tools.
Language definitions are XML files defined in ./lexers/embedded/*.xml. Language definitions are XML files defined in ./lexers/embedded/*.xml.
+93
View File
@@ -0,0 +1,93 @@
let version = exec("git describe --tags --dirty --always") | trim
# TinyGo's installation root; used to source `wasm_exec.js`.
let tinygoroot = exec("tinygo env TINYGOROOT") | trim
# Generate tokentype_enumer.go from types.go via `//go:generate`.
tokentype = go.generate {
package = "."
inputs = ["types.go"]
outputs = ["tokentype_enumer.go"]
}
# Regenerate the lexer table in README.md by invoking the host `chroma` binary.
# GOOS/GOARCH are cleared so cross-compile env vars don't break the local run.
protected readme = exec {
command = "./table.py"
inputs = ["table.py", "lexers/**/*.go", "lexers/**/*.xml"]
output = "README.md"
}
# Format frontend JS sources in place. Runs as a sub-step of `index-min-js`,
# so bundling always sees formatted sources.
format-js = exec {
command = "biome format --write cmd/chromad/static/index.js cmd/chromad/static/chroma.js"
inputs = ["biome.js", "cmd/chromad/static/index.js", "cmd/chromad/static/chroma.js"]
}
# Copy TinyGo's wasm_exec.js into the chromad static assets.
wasm-exec = exec {
command = "install -m644 '#{tinygoroot}/targets/wasm_exec.js' cmd/chromad/static/wasm_exec.js"
resolve = "sha256 '#{tinygoroot}/targets/wasm_exec.js'"
output = "cmd/chromad/static/wasm_exec.js"
}
# Build the chroma WASM module via tinygo (installed via hermit) for the
# smaller output binary.
chroma-wasm = exec {
command = "tinygo build -no-debug -target wasm -o cmd/chromad/static/chroma.wasm cmd/libchromawasm/main.go"
inputs = ["cmd/libchromawasm/**/*.go", "*.go", "lexers/**/*.go", "lexers/**/*.xml", "formatters/**/*.go", "styles/**/*.go"]
output = "cmd/chromad/static/chroma.wasm"
}
# Bundle and minify the frontend JS. Depends on `format-js` so the bundle
# always reflects formatted sources.
index-min-js = exec {
command = "esbuild --platform=browser --format=esm --bundle cmd/chromad/static/index.js --minify --external:./wasm_exec.js --outfile=cmd/chromad/static/index.min.js"
inputs = ["cmd/chromad/static/index.js", "cmd/chromad/static/chroma.js"]
output = "cmd/chromad/static/index.min.js"
depends_on = [format-js]
}
# Bundle and minify the frontend CSS.
index-min-css = exec {
command = "esbuild --bundle cmd/chromad/static/index.css --minify --outfile=cmd/chromad/static/index.min.css"
inputs = ["cmd/chromad/static/index.css", "cmd/chromad/static/bulma.css"]
output = "cmd/chromad/static/index.min.css"
}
# Build the chromad server binary. cmd/chromad is a separate Go module, so
# `dir` puts the build in there and `package = "."` resolves against that
# module. `output` stays project-root-relative; bit absolutises it before
# passing to `go build -o`. Defaults to linux/amd64 to match the deploy
# target; override with GOOS/GOARCH env vars for local builds.
chromad = go.exe {
dir = "cmd/chromad"
package = "."
output = "build/chromad"
flags = ["-ldflags", "-X 'main.version=#{version}'"]
goos = env("GOOS", "linux")
goarch = env("GOARCH", "amd64")
cgo = false
depends_on = [wasm-exec, chroma-wasm, index-min-js, index-min-css, test]
}
pre format-go = go.fmt {
package = "./..."
}
# Run Go tests.
test = go.test {
package = "./..."
}
# Deploy chromad to swapoff.org. Must be explicitly selected.
explicit upload = exec {
command = <<-EOF
scp #{chromad.path} root@swapoff.org:
ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'
EOF
depends_on = [chromad]
}
target default = [test, chromad, readme, tokentype]
-24
View File
@@ -1,24 +0,0 @@
VERSION = %(git describe --tags --dirty --always)%
export CGOENABLED = 0
tokentype_enumer.go: types.go
build: go generate
# Regenerate the list of lexers in the README
README.md: lexers/*.go lexers/*/*.xml table.py
build: ./table.py
-clean
implicit %{1}%{2}.min.%{3}: **/*.{css,js}
build: esbuild --bundle %{IN} --minify --outfile=%{OUT}
implicit build/%{1}: cmd/*
cd cmd/%{1}
inputs: cmd/%{1}/**/* **/*.go
build: go build -ldflags="-X 'main.version=%{VERSION}'" -o ../../build/%{1} .
#upload: chromad
# build:
# scp chromad root@swapoff.org:
# ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'
# touch upload
+1 -1
View File
@@ -28,7 +28,7 @@ ENV GOARCH=amd64
RUN just chromad RUN just chromad
# Runtime stage # Runtime stage
FROM alpine:3.23 AS runtime FROM alpine:3.24 AS runtime
# Install ca-certificates for HTTPS requests # Install ca-certificates for HTTPS requests
RUN apk --no-cache add ca-certificates curl RUN apk --no-cache add ca-certificates curl
+5 -1
View File
@@ -1,4 +1,4 @@
set positional-arguments := true set positional-arguments
set shell := ["bash", "-c"] set shell := ["bash", "-c"]
version := `git describe --tags --dirty --always` version := `git describe --tags --dirty --always`
@@ -21,6 +21,10 @@ tokentype-string:
format-js: format-js:
biome format --write cmd/chromad/static/index.js cmd/chromad/static/chroma.js biome format --write cmd/chromad/static/index.js cmd/chromad/static/chroma.js
# Tidy Go modules
tidy:
find . -name 'go.mod' -execdir go mod tidy \;
# Build chromad binary # Build chromad binary
chromad: wasm-exec chroma-wasm chromad: wasm-exec chroma-wasm
#!/usr/bin/env bash #!/usr/bin/env bash
+9 -7
View File
@@ -36,25 +36,25 @@ translators for Pygments lexers and styles.
| Prefix | Language | Prefix | Language
| :----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| A | ABAP, ABNF, ActionScript, ActionScript 3, Ada, Agda, AL, Alloy, AMPL, Angular2, ANTLR, ApacheConf, APL, AppleScript, ArangoDB AQL, Arduino, ArmAsm, ATL, AutoHotkey, AutoIt, Awk | A | ABAP, ABNF, ActionScript, ActionScript 3, Ada, Agda, AL, Alloy, AMPL, Angular2, ANTLR, ApacheConf, APL, AppleScript, ArangoDB AQL, Arduino, ArmAsm, Arturo, ATL, AutoHotkey, AutoIt, Awk
| B | Ballerina, Bash, Bash Session, Batchfile, Beef, BibTeX, Bicep, BlitzBasic, BNF, BQN, Brainfuck | B | Ballerina, Bash, Bash Session, Batchfile, Beef, BibTeX, Bicep, BlitzBasic, BNF, BQN, Brainfuck
| C | C, C#, C++, C3, Caddyfile, Caddyfile Directives, Cap'n Proto, Cassandra CQL, Ceylon, CFEngine3, cfstatement, ChaiScript, Chapel, Cheetah, Clojure, CMake, COBOL, CoffeeScript, Common Lisp, Coq, Core, Crystal, CSS, CSV, CUE, Cython | C | C, C#, C++, C3, Caddyfile, Caddyfile Directives, Cap'n Proto, Cassandra CQL, Ceylon, CFEngine3, cfstatement, ChaiScript, Chapel, Cheetah, Clojure, CMake, COBOL, CoffeeScript, Common Lisp, Coq, Core, Crystal, CSS, CSV, CUE, Cython
| D | D, Dart, Dax, Desktop file, Diff, Django/Jinja, dns, Docker, DTD, Dylan | D | D, Dart, Dax, Desktop file, Devicetree, Diff, Django/Jinja, dns, Docker, DTD, Dylan
| E | EBNF, Elixir, Elm, EmacsLisp, Erlang | E | EBNF, Elixir, Elm, EmacsLisp, ERB, Erlang
| F | Factor, Fennel, Fish, Forth, Fortran, FortranFixed, FSharp | F | Factor, Fennel, Fish, Forth, Fortran, FortranFixed, FSharp
| G | GAS, GDScript, GDScript3, Gemtext, Genshi, Genshi HTML, Genshi Text, Gettext, Gherkin, Gleam, GLSL, Gnuplot, Go, Go HTML Template, Go Template, Go Text Template, GraphQL, Groff, Groovy | G | GAS, GDScript, GDScript3, Gemtext, Genshi, Genshi HTML, Genshi Text, Gettext, Gherkin, Gleam, GLSL, Gnuplot, Go, Go HTML Template, Go Template, Go Text Template, GraphQL, Groff, Groovy
| H | Handlebars, Hare, Haskell, Haxe, HCL, Hexdump, HLB, HLSL, HolyC, HTML, HTTP, Hy | H | Handlebars, Hare, Haskell, Haxe, HCL, Hexdump, HLB, HLSL, HolyC, HTML, HTTP, Hy
| I | Idris, Igor, INI, Io, ISCdhcpd | I | Idris, Igor, INI, Io, ISCdhcpd
| J | J, Janet, Java, JavaScript, JSON, JSONata, Jsonnet, Julia, Jungle | J | J, Janet, Java, JavaScript, JSON, JSONata, Jsonnet, Julia, Jungle
| K | Kakoune, Kotlin | K | Kakoune, KDL, Kotlin
| L | Lean4, Lighttpd configuration file, LLVM, lox, Lua, Luau | L | Lateralus, Lean4, Lighttpd configuration file, LilyPond, LLVM, lox, Lua, Luau
| M | Makefile, Mako, markdown, Markless, Mason, Materialize SQL dialect, Mathematica, Matlab, MCFunction, Meson, Metal, MiniZinc, MLIR, Modelica, Modula-2, Mojo, MonkeyC, MoonScript, MorrowindScript, Myghty, MySQL | M | Makefile, Mako, markdown, Markless, Mason, Materialize SQL dialect, Mathematica, Matlab, MCFunction, Meson, Metal, microcad, MiniZinc, MLIR, Modelica, Modula-2, Mojo, MonkeyC, MoonBit, MoonScript, MorrowindScript, Myghty, MySQL
| N | NASM, Natural, NDISASM, Newspeak, Nginx configuration file, Nim, Nix, NSIS, Nu | N | NASM, Natural, NDISASM, Newspeak, Nginx configuration file, Nim, Nix, NSIS, Nu
| O | Objective-C, ObjectPascal, OCaml, Octave, Odin, OnesEnterprise, OpenEdge ABL, OpenSCAD, Org Mode | O | Objective-C, ObjectPascal, OCaml, Octave, Odin, OnesEnterprise, OpenEdge ABL, OpenSCAD, Org Mode
| P | PacmanConf, Perl, PHP, PHTML, Pig, PkgConfig, PL/pgSQL, plaintext, Plutus Core, Pony, PostgreSQL SQL dialect, PostScript, POVRay, PowerQuery, PowerShell, Prolog, Promela, PromQL, properties, Protocol Buffer, Protocol Buffer Text Format, PRQL, PSL, Puppet, Python, Python 2 | P | PacmanConf, Perl, PHP, PHTML, Pig, PkgConfig, PL/pgSQL, plaintext, Plutus Core, Pony, PostgreSQL SQL dialect, PostScript, POVRay, PowerQuery, PowerShell, Prolog, Promela, PromQL, properties, Protocol Buffer, Protocol Buffer Text Format, PRQL, PSL, Puppet, Python, Python 2
| Q | QBasic, QML | Q | QBasic, QML
| R | R, Racket, Ragel, Raku, react, ReasonML, reg, Rego, reStructuredText, Rexx, RGBDS Assembly, Ring, RPGLE, RPMSpec, Ruby, Rust | R | R, Racket, Ragel, Raku, react, ReasonML, reg, Rego, reStructuredText, Rexx, RGBDS Assembly, Ring, RPGLE, RPMSpec, Ruby, Rust
| S | SAS, Sass, Scala, Scheme, Scilab, SCSS, Sed, Sieve, Smali, Smalltalk, Smarty, SNBT, Snobol, Solidity, SourcePawn, Spade, SPARQL, SQL, SquidConf, Standard ML, stas, Stylus, Svelte, Swift, SYSTEMD, systemverilog | S | SAS, Sass, Scala, scdoc, Scheme, Scilab, SCSS, Sed, Sieve, Smali, Smalltalk, Smarty, SNBT, Snobol, Solidity, SourcePawn, Spade, SPARQL, SQL, SquidConf, Standard ML, stas, Stylus, Svelte, Swift, SYSTEMD, systemverilog
| T | TableGen, Tal, TASM, Tcl, Tcsh, Termcap, Terminfo, Terraform, TeX, Thrift, TOML, TradingView, Transact-SQL, Turing, Turtle, Twig, TypeScript, TypoScript, TypoScriptCssData, TypoScriptHtmlData, Typst | T | TableGen, Tal, TASM, Tcl, Tcsh, Termcap, Terminfo, Terraform, TeX, Thrift, TOML, TradingView, Transact-SQL, Turing, Turtle, Twig, TypeScript, TypoScript, TypoScriptCssData, TypoScriptHtmlData, Typst
| U | ucode | U | ucode
| V | V, V shell, Vala, VB.net, verilog, VHDL, VHS, VimL, vue | V | V, V shell, Vala, VB.net, verilog, VHDL, VHS, VimL, vue
@@ -276,6 +276,8 @@ for that setup the `chroma` executable can be just symlinked to `~/.lessfilter`.
its input using Chroma its input using Chroma
* [Hugo](https://gohugo.io/) is a static site generator that [uses Chroma for syntax * [Hugo](https://gohugo.io/) is a static site generator that [uses Chroma for syntax
highlighting code examples](https://gohugo.io/content-management/syntax-highlighting/) highlighting code examples](https://gohugo.io/content-management/syntax-highlighting/)
* [f4](https://github.com/unxed/f4) is asynchronious cross platform Far Manager clone in Go
that uses Chroma for syntax highlighting in built-in editor
## Testing lexers ## Testing lexers
+1 -1
View File
@@ -52,7 +52,7 @@ type Colour int32
// NewColour creates a Colour directly from RGB values. // NewColour creates a Colour directly from RGB values.
func NewColour(r, g, b uint8) Colour { func NewColour(r, g, b uint8) Colour {
return ParseColour(fmt.Sprintf("%02x%02x%02x", r, g, b)) return Colour(int32(r)<<16|int32(g)<<8|int32(b)) + 1
} }
// Distance between this colour and another. // Distance between this colour and another.
+57 -13
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"html" "html"
"io" "io"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -83,6 +84,11 @@ func WithPreWrapper(wrapper PreWrapper) Option {
} }
} }
// WithModeClasses adds the style's mode (eg. "light" or "dark") as a CSS
// class on wrapper elements and scopes WriteCSS rules by mode. This enables
// combining light and dark stylesheets and switching themes at runtime.
func WithModeClasses(b bool) Option { return func(f *Formatter) { f.modeClasses = b } }
// WrapLongLines wraps long lines. // WrapLongLines wraps long lines.
func WrapLongLines(b bool) Option { func WrapLongLines(b bool) Option {
return func(f *Formatter) { return func(f *Formatter) {
@@ -206,6 +212,7 @@ type Formatter struct {
inlineCode bool inlineCode bool
preventSurroundingPre bool preventSurroundingPre bool
tabWidth int tabWidth int
modeClasses bool
wrapLongLines bool wrapLongLines bool
lineNumbers bool lineNumbers bool
lineNumbersInTable bool lineNumbersInTable bool
@@ -241,7 +248,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background]) fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background])
fmt.Fprint(w, "</style>") fmt.Fprint(w, "</style>")
} }
fmt.Fprintf(w, "<body%s>\n", f.styleAttr(css, chroma.Background)) fmt.Fprintf(w, "<body%s>\n", f.styleAttrWithMode(css, chroma.Background, style))
} }
wrapInTable := f.lineNumbers && f.lineNumbersInTable wrapInTable := f.lineNumbers && f.lineNumbersInTable
@@ -252,10 +259,10 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
if wrapInTable { if wrapInTable {
// List line numbers in its own <td> // List line numbers in its own <td>
fmt.Fprintf(w, "<div%s>\n", f.styleAttr(css, chroma.PreWrapper)) fmt.Fprintf(w, "<div%s>\n", f.styleAttrWithMode(css, chroma.PreWrapper, style))
fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable)) fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable))
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD)) fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD))
fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttr(css, chroma.PreWrapper))) fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttrWithMode(css, chroma.PreWrapper, style)))
for index := range lines { for index := range lines {
line := f.baseLineNumber + index line := f.baseLineNumber + index
highlight, next := f.shouldHighlight(highlightIndex, line) highlight, next := f.shouldHighlight(highlightIndex, line)
@@ -277,7 +284,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%")) fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%"))
} }
fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttr(css, chroma.PreWrapper))) fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttrWithMode(css, chroma.PreWrapper, style)))
highlightIndex = 0 highlightIndex = 0
for index, tokens := range lines { for index, tokens := range lines {
@@ -288,7 +295,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
highlightIndex++ highlightIndex++
} }
if !(f.preventSurroundingPre || f.inlineCode) { if !f.preventSurroundingPre && !f.inlineCode {
// Start of Line // Start of Line
fmt.Fprint(w, `<span`) fmt.Fprint(w, `<span`)
@@ -321,7 +328,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
fmt.Fprint(w, html) fmt.Fprint(w, html)
} }
if !(f.preventSurroundingPre || f.inlineCode) { if !f.preventSurroundingPre && !f.inlineCode {
fmt.Fprint(w, `</span>`) // End of CodeLine fmt.Fprint(w, `</span>`) // End of CodeLine
fmt.Fprint(w, `</span>`) // End of Line fmt.Fprint(w, `</span>`) // End of Line
@@ -414,6 +421,26 @@ func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.Toke
return fmt.Sprintf(` style="%s"`, strings.Join(css, ";")) return fmt.Sprintf(` style="%s"`, strings.Join(css, ";"))
} }
// modeClass returns the CSS class corresponding to the style's mode (eg.
// "light" or "dark"), with the formatter's class prefix applied.
func (f *Formatter) modeClass(style *chroma.Style) string {
return f.prefix + style.Mode().String()
}
// styleAttrWithMode is like styleAttr but, in classes mode, appends the
// style's mode class alongside the existing class. Used for the outer
// wrapper and standalone <body> so external CSS can target the mode.
func (f *Formatter) styleAttrWithMode(styles map[chroma.TokenType]string, tt chroma.TokenType, style *chroma.Style) string {
if !f.Classes || !f.modeClasses {
return f.styleAttr(styles, tt)
}
cls := f.class(tt)
if cls == "" {
return ""
}
return fmt.Sprintf(` class="%s %s"`, cls, f.modeClass(style))
}
func (f *Formatter) tabWidthStyle() string { func (f *Formatter) tabWidthStyle() string {
if f.tabWidth != 0 && f.tabWidth != 8 { if f.tabWidth != 0 && f.tabWidth != 8 {
return fmt.Sprintf("-moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d;", f.tabWidth) return fmt.Sprintf("-moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d;", f.tabWidth)
@@ -437,20 +464,38 @@ func (f *Formatter) writeCSSRule(w io.Writer, comment string, selector string, s
} }
// WriteCSS writes CSS style definitions (without any surrounding HTML). // WriteCSS writes CSS style definitions (without any surrounding HTML).
//
// Rules are scoped by the style's mode (eg. ".chroma.dark") so that CSS
// generated from a light and dark style can be combined without conflict.
// To support dynamic theme switching, call WriteCSS with both styles,
// concatenate the output, and toggle the wrapper's mode class (added
// automatically by Format) at runtime. Tokens that one theme leaves
// unstyled fall back to that theme's ".chroma.<mode>" text/background
// via the CSS cascade; pass WithAllClasses(true) if you need every
// token's rule materialised explicitly for both themes.
func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error { func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
css := f.styleCache.get(style, false) css := f.styleCache.get(style, false)
var chromaSel, bgSel string
if f.modeClasses {
modeCls := f.modeClass(style)
chromaSel = fmt.Sprintf(".%schroma.%s", f.prefix, modeCls)
bgSel = fmt.Sprintf(".%sbg.%s", f.prefix, modeCls)
} else {
chromaSel = fmt.Sprintf(".%schroma", f.prefix)
bgSel = fmt.Sprintf(".%sbg", f.prefix)
}
// Special-case background as it is mapped to the outer ".chroma" class. // Special-case background as it is mapped to the outer ".chroma" class.
if err := f.writeCSSRule(w, chroma.Background.String(), fmt.Sprintf(".%sbg", f.prefix), css[chroma.Background]); err != nil { if err := f.writeCSSRule(w, chroma.Background.String(), bgSel, css[chroma.Background]); err != nil {
return err return err
} }
// Special-case PreWrapper as it is the ".chroma" class. // Special-case PreWrapper as it is the ".chroma" class.
if err := f.writeCSSRule(w, chroma.PreWrapper.String(), fmt.Sprintf(".%schroma", f.prefix), css[chroma.PreWrapper]); err != nil { if err := f.writeCSSRule(w, chroma.PreWrapper.String(), chromaSel, css[chroma.PreWrapper]); err != nil {
return err return err
} }
// Special-case code column of table to expand width. // Special-case code column of table to expand width.
if f.lineNumbers && f.lineNumbersInTable { if f.lineNumbers && f.lineNumbersInTable {
selector := fmt.Sprintf(".%schroma .%s:last-child", f.prefix, f.class(chroma.LineTableTD)) selector := fmt.Sprintf("%s .%s:last-child", chromaSel, f.class(chroma.LineTableTD))
if err := f.writeCSSRule(w, chroma.LineTableTD.String(), selector, "width: 100%;"); err != nil { if err := f.writeCSSRule(w, chroma.LineTableTD.String(), selector, "width: 100%;"); err != nil {
return err return err
} }
@@ -460,7 +505,7 @@ func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
targetedLineCSS := StyleEntryToCSS(style.Get(chroma.LineHighlight)) targetedLineCSS := StyleEntryToCSS(style.Get(chroma.LineHighlight))
for _, tt := range []chroma.TokenType{chroma.LineNumbers, chroma.LineNumbersTable} { for _, tt := range []chroma.TokenType{chroma.LineNumbers, chroma.LineNumbersTable} {
comment := fmt.Sprintf("%s targeted by URL anchor", tt) comment := fmt.Sprintf("%s targeted by URL anchor", tt)
selector := fmt.Sprintf(".%schroma .%s:target", f.prefix, f.class(tt)) selector := fmt.Sprintf("%s .%s:target", chromaSel, f.class(tt))
if err := f.writeCSSRule(w, comment, selector, targetedLineCSS); err != nil { if err := f.writeCSSRule(w, comment, selector, targetedLineCSS); err != nil {
return err return err
} }
@@ -481,7 +526,7 @@ func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
if class == "" { if class == "" {
continue continue
} }
if err := f.writeCSSRule(w, tt.String(), fmt.Sprintf(".%schroma .%s", f.prefix, class), css[tt]); err != nil { if err := f.writeCSSRule(w, tt.String(), fmt.Sprintf("%s .%s", chromaSel, class), css[tt]); err != nil {
return err return err
} }
} }
@@ -613,8 +658,7 @@ func (l *styleCache) get(style *chroma.Style, compress bool) map[chroma.TokenTyp
defer l.mu.Unlock() defer l.mu.Unlock()
// Look for an existing entry. // Look for an existing entry.
for i := len(l.cache) - 1; i >= 0; i-- { for i, entry := range slices.Backward(l.cache) {
entry := l.cache[i]
if entry.style == style && entry.compressed == compress { if entry.style == style && entry.compressed == compress {
// Top of the cache, no need to adjust the order. // Top of the cache, no need to adjust the order.
if i == len(l.cache)-1 { if i == len(l.cache)-1 {
@@ -0,0 +1,119 @@
<lexer>
<config>
<name>Arturo</name>
<alias>arturo</alias>
<alias>art</alias>
<filename>*.art</filename>
</config>
<rules>
<state name="root">
<rule pattern=";.*?$"><token type="CommentSingle"/></rule>
<rule pattern="^((\s#!)|(#!)).*?$"><token type="CommentHashbang"/></rule>
<rule pattern="(false|true|maybe)\b"><token type="NameConstant"/></rule>
<rule pattern="\b(this|init)\b\??:?"><token type="NameBuiltinPseudo"/></rule>
<rule pattern="`.`"><token type="LiteralStringChar"/></rule>
<rule pattern="\\\w+\b\??:?"><token type="NameProperty"/></rule>
<rule pattern="#\w+"><token type="NameConstant"/></rule>
<rule pattern="\b[0-9]+\.[0-9]+"><token type="LiteralNumberFloat"/></rule>
<rule pattern="\b[0-9]+"><token type="LiteralNumberInteger"/></rule>
<rule pattern="\w+\b\??:"><token type="NameLabel"/></rule>
<rule pattern="\&#x27;(?:\w+\b\??:?)"><token type="KeywordDeclaration"/></rule>
<rule pattern="\:\w+"><token type="KeywordType"/></rule>
<rule pattern="\.\w+\??:?"><token type="NameAttribute"/></rule>
<rule pattern="(\()(.*?)(\)\?)"><bygroups><token type="Punctuation"/><usingself state="root"/><token type="Punctuation"/></bygroups></rule>
<rule pattern="&quot;"><token type="LiteralStringDouble"/><push state="inside-simple-string"/></rule>
<rule pattern="»"><token type="LiteralStringSingle"/><push state="inside-smart-string"/></rule>
<rule pattern="«««"><token type="LiteralStringDouble"/><push state="inside-safe-string"/></rule>
<rule pattern="\{\/"><token type="LiteralStringSingle"/><push state="inside-regex-string"/></rule>
<rule pattern="\{\:"><token type="LiteralStringDouble"/><push state="inside-curly-verb-string"/></rule>
<rule pattern="(\{)(\!)(\w+)(\s|\n)([\w\W]*?)(^\})">
<usingbygroup>
<sublexer_name_group>3</sublexer_name_group>
<code_group>5</code_group>
<emitters>
<token type="LiteralStringDouble"/>
<token type="LiteralStringInterpol"/>
<token type="LiteralStringInterpol"/>
<token type="TextWhitespace"/>
<token type="LiteralString"/>
<token type="LiteralStringDouble"/>
</emitters>
</usingbygroup>
</rule>
<rule pattern="\{"><token type="LiteralStringSingle"/><push state="inside-curly-string"/></rule>
<rule pattern="\-{3,}"><token type="LiteralStringSingle"/><push state="inside-eof-string"/></rule>
<rule><include state="builtin-functions"/></rule>
<rule pattern="[()[\],]"><token type="Punctuation"/></rule>
<rule pattern="(\-&gt;|==&gt;|\||::|@|\#|\$|\&amp;|!|!!|\./)"><token type="NameDecorator"/></rule>
<rule pattern="(&lt;:|:&gt;|:&lt;|&gt;:|&lt;\\|&lt;&gt;|&lt;|&gt;|ø|∞|\+|\-|\*|\~|=|\^|%|/|//|==&gt;|&lt;=&gt;|&lt;==&gt;|=&gt;&gt;|&lt;&lt;=&gt;&gt;|&lt;&lt;==&gt;&gt;|\-\-&gt;|&lt;\-&gt;|&lt;\-\-&gt;|=\||\|=|\-:|:\-|_|\.|\.\.|\\)"><token type="Operator"/></rule>
<rule pattern="\b\w+"><token type="Name"/></rule>
<rule pattern="\s+"><token type="TextWhitespace"/></rule>
<rule pattern=".+$"><token type="Error"/></rule>
</state>
<state name="inside-interpol">
<rule pattern="\|"><token type="LiteralStringInterpol"/><pop depth="1"/></rule>
<rule pattern="[^|]+"><usingself state="root"/></rule>
</state>
<state name="inside-template">
<rule pattern="\|\|\&gt;"><token type="LiteralStringInterpol"/><pop depth="1"/></rule>
<rule pattern="[^|]+"><usingself state="root"/></rule>
</state>
<state name="string-escape">
<rule pattern="(\\\\|\\n|\\t|\\&quot;)"><token type="LiteralStringEscape"/></rule>
</state>
<state name="inside-simple-string">
<rule><include state="string-escape"/></rule>
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
<rule pattern="\&lt;\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
<rule pattern="&quot;"><token type="LiteralStringDouble"/><pop depth="1"/></rule>
<rule pattern="[^|&quot;]+"><token type="LiteralString"/></rule>
</state>
<state name="inside-smart-string">
<rule><include state="string-escape"/></rule>
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
<rule pattern="\&lt;\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
<rule pattern="\n"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
<rule pattern="[^|\n]+"><token type="LiteralString"/></rule>
</state>
<state name="inside-safe-string">
<rule><include state="string-escape"/></rule>
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
<rule pattern="\&lt;\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
<rule pattern="»»»"><token type="LiteralStringDouble"/><pop depth="1"/></rule>
<rule pattern="[^|»]+"><token type="LiteralString"/></rule>
</state>
<state name="inside-regex-string">
<rule pattern="\\[sSwWdDbBZApPxucItnvfr0]+"><token type="LiteralStringEscape"/></rule>
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
<rule pattern="\&lt;\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
<rule pattern="\/\}"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
<rule pattern="[^|\/]+"><token type="LiteralStringRegex"/></rule>
</state>
<state name="inside-curly-verb-string">
<rule><include state="string-escape"/></rule>
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
<rule pattern="\&lt;\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
<rule pattern="\:\}"><token type="LiteralStringDouble"/><pop depth="1"/></rule>
<rule pattern="[^|&lt;:]+"><token type="LiteralString"/></rule>
</state>
<state name="inside-curly-string">
<rule><include state="string-escape"/></rule>
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
<rule pattern="\&lt;\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
<rule pattern="\}"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
<rule pattern="[^|&lt;}]+"><token type="LiteralString"/></rule>
</state>
<state name="inside-eof-string">
<rule><include state="string-escape"/></rule>
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
<rule pattern="\&lt;\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
<rule pattern="\Z"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
<rule pattern="[^|&lt;]+"><token type="LiteralString"/></rule>
</state>
<state name="builtin-functions">
<rule pattern="\b(all|and|any|ascii|attr|attribute|attributeLabel|binary|blockchar|contains|database|date|dictionary|empty|equal|even|every|exists|false|floatin|function|greater|greaterOrEqual|if|in|inline|integer|is|key|label|leap|less|lessOrEqual|literal|logical|lower|nand|negative|nor|not|notEqual|null|numeric|odd|or|path|pathLabel|positive|prefix|prime|set|some|sorted|standalone|string|subset|suffix|superset|ymbol|true|try|type|unless|upper|when|whitespace|word|xnor|xor|zero)\b\?"><token type="NameBuiltin"/></rule>
<rule pattern="\b(abs|acos|acosh|acsec|acsech|actan|actanh|add|after|alphabet|and|angle|append|arg|args|arity|array|as|asec|asech|asin|asinh|atan|atan2|atanh|attr|attrs|average|before|benchmark|blend|break|builtins1|builtins2|call|capitalize|case|ceil|chop|chunk|clear|close|cluster|color|combine|conj|continue|copy|cos|cosh|couple|csec|csech|ctan|ctanh|cursor|darken|dec|decode|decouple|define|delete|desaturate|deviation|dictionary|difference|digest|digits|div|do|download|drop|dup|e|else|empty|encode|ensure|env|epsilon|escape|execute|exit|exp|extend|extract|factors|false|fdiv|filter|first|flatten|floor|fold|from|function|gamma|gcd|get|goto|hash|help|hypot|if|in|inc|indent|index|infinity|info|input|insert|inspect|intersection|invert|join|keys|kurtosis|last|let|levenshtein|lighten|list|ln|log|loop|lower|mail|map|match|max|maybe|median|min|mod|module|mul|nand|neg|new|nor|normalize|not|now|null|open|or|outdent|pad|panic|path|pause|permissions|permutate|pi|pop|pow|powerset|powmod|prefix|print|prints|process|product|query|random|range|read|relative|remove|rename|render|repeat|replace|request|return|reverse|round|sample|saturate|script|sec|sech|select|serve|set|shl|shr|shuffle|sin|sinh|size|skewness|slice|sort|split|sqrt|squeeze|stack|strip|sub|suffix|sum|switch|symbols|symlink|sys|take|tan|tanh|terminal|to|true|truncate|try|type|union|unique|unless|until|unzip|upper|values|var|variance|volume|webview|while|with|wordwrap|write|xnor|xor|zip)\b"><token type="NameBuiltin"/></rule>
</state>
</rules>
</lexer>
+1 -1
View File
@@ -81,7 +81,7 @@
<rule pattern="[^\S\n]+"> <rule pattern="[^\S\n]+">
<token type="Text"/> <token type="Text"/>
</rule> </rule>
<rule pattern="//.*?\n"> <rule pattern="//[^\n]*\n?">
<token type="CommentSingle"/> <token type="CommentSingle"/>
</rule> </rule>
<rule pattern="/\*.*?\*/"> <rule pattern="/\*.*?\*/">
@@ -0,0 +1,81 @@
<lexer>
<config>
<name>Gemfile.lock</name>
<alias>gemfile-lock</alias>
<alias>gemfilelock</alias>
<filename>Gemfile.lock</filename>
<filename>*.gemfile.lock</filename>
</config>
<rules>
<state name="root">
<rule pattern="^(GIT|PATH|GEM|PLUGIN SOURCE|PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION|CHECKSUMS)$">
<token type="Keyword"/>
</rule>
<rule pattern="^([ \t]+)(remote|revision|ref|branch|tag|submodules|specs|glob)(:)">
<bygroups>
<token type="Text"/>
<token type="NameAttribute"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="\(">
<token type="Punctuation"/>
<push state="version"/>
</rule>
<rule pattern="!">
<token type="Operator"/>
</rule>
<rule pattern="https?://\S+">
<token type="LiteralStringSymbol"/>
</rule>
<rule pattern="git@\S+">
<token type="LiteralStringSymbol"/>
</rule>
<rule pattern="sha\d+=[A-Fa-f0-9]+">
<token type="LiteralNumberHex"/>
</rule>
<rule pattern="\b[a-f0-9]{7,40}\b">
<token type="LiteralNumberHex"/>
</rule>
<rule pattern="\b\d[\w.]*">
<token type="LiteralNumber"/>
</rule>
<rule pattern="[A-Za-z_][A-Za-z0-9_.-]*">
<token type="Name"/>
</rule>
<rule pattern="\n">
<token type="Text"/>
</rule>
<rule pattern="[ \t]+">
<token type="Text"/>
</rule>
<rule pattern=".">
<token type="Text"/>
</rule>
</state>
<state name="version">
<rule pattern="\)">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule pattern="(~&gt;|&gt;=|&lt;=|!=|=|&lt;|&gt;)">
<token type="Operator"/>
</rule>
<rule pattern="[0-9][\w.]*">
<token type="LiteralNumber"/>
</rule>
<rule pattern="[A-Za-z][\w.-]*">
<token type="Name"/>
</rule>
<rule pattern=",">
<token type="Punctuation"/>
</rule>
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern=".">
<token type="Text"/>
</rule>
</state>
</rules>
</lexer>
+16 -3
View File
@@ -32,7 +32,7 @@
pattern="(assert|break|case|catch|continue|default|do|else|finally|for|if|goto|instanceof|new|return|switch|this|throw|try|while)\b"> pattern="(assert|break|case|catch|continue|default|do|else|finally|for|if|goto|instanceof|new|return|switch|this|throw|try|while)\b">
<token type="Keyword" /> <token type="Keyword" />
</rule> </rule>
<rule pattern="((?:(?:[^\W\d]|\$)[\w.\[\]$&lt;&gt;]*\s+)+?)((?:[^\W\d]|\$)[\w$]*)(\s*)(\()"> <rule pattern="((?:(?:[^\W\d]|\$)[\w.\[\]$&lt;&gt;?]*\s+)+?)((?:[^\W\d]|\$)[\w$]*)(\s*)(\()">
<bygroups> <bygroups>
<usingself state="root" /> <usingself state="root" />
<token type="NameFunction" /> <token type="NameFunction" />
@@ -44,7 +44,7 @@
<token type="NameDecorator" /> <token type="NameDecorator" />
</rule> </rule>
<rule <rule
pattern="(abstract|const|enum|extends|final|implements|native|private|protected|public|sealed|static|strictfp|super|synchronized|throws|transient|volatile|yield)\b"> pattern="(abstract|const|enum|exports|extends|final|implements|native|non-sealed|open|opens|permits|private|protected|provides|public|requires|sealed|static|strictfp|super|synchronized|throws|to|transient|transitive|uses|volatile|with|yield)\b">
<token type="KeywordDeclaration" /> <token type="KeywordDeclaration" />
</rule> </rule>
<rule pattern="(boolean|byte|char|double|float|int|long|short|void)\b"> <rule pattern="(boolean|byte|char|double|float|int|long|short|void)\b">
@@ -64,6 +64,10 @@
<token type="KeywordDeclaration" /> <token type="KeywordDeclaration" />
<push state="class" /> <push state="class" />
</rule> </rule>
<rule pattern="(module)\b">
<token type="KeywordDeclaration" />
<push state="module" />
</rule>
<rule pattern="(var)(\s+)"> <rule pattern="(var)(\s+)">
<bygroups> <bygroups>
<token type="KeywordDeclaration" /> <token type="KeywordDeclaration" />
@@ -71,7 +75,7 @@
</bygroups> </bygroups>
<push state="var" /> <push state="var" />
</rule> </rule>
<rule pattern="(import(?:\s+static)?)(\s+)"> <rule pattern="(import(?:\s+(?:static|module))?)(\s+)">
<bygroups> <bygroups>
<token type="KeywordNamespace" /> <token type="KeywordNamespace" />
<token type="TextWhitespace" /> <token type="TextWhitespace" />
@@ -147,6 +151,15 @@
<pop depth="1" /> <pop depth="1" />
</rule> </rule>
</state> </state>
<state name="module">
<rule pattern="\s+">
<token type="Text" />
</rule>
<rule pattern="([^\W\d]|\$)[\w$]*">
<token type="NameClass" />
<pop depth="1" />
</rule>
</state>
<state name="var"> <state name="var">
<rule pattern="([^\W\d]|\$)[\w$]*"> <rule pattern="([^\W\d]|\$)[\w$]*">
<token type="Name" /> <token type="Name" />
@@ -2,12 +2,15 @@
<config> <config>
<name>JSON</name> <name>JSON</name>
<alias>json</alias> <alias>json</alias>
<alias>jsonl</alias>
<filename>*.json</filename> <filename>*.json</filename>
<filename>*.jsonl</filename>
<filename>*.jsonc</filename> <filename>*.jsonc</filename>
<filename>*.json5</filename> <filename>*.json5</filename>
<filename>*.avsc</filename> <filename>*.avsc</filename>
<filename>.luaurc</filename> <filename>.luaurc</filename>
<mime_type>application/json</mime_type> <mime_type>application/json</mime_type>
<mime_type>application/jsonl</mime_type>
<dot_all>true</dot_all> <dot_all>true</dot_all>
<not_multiline>true</not_multiline> <not_multiline>true</not_multiline>
</config> </config>
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -3,7 +3,12 @@
<name>Nu</name> <name>Nu</name>
<alias>nu</alias> <alias>nu</alias>
<filename>*.nu</filename> <filename>*.nu</filename>
<mime_type>application/x-shellscript</mime_type>
<mime_type>text/plain</mime_type> <mime_type>text/plain</mime_type>
<mime_type>text/x-shellscript</mime_type>
<analyse first="true" >
<regex pattern="(?m)^#!.*/bin/(?:env(?: -[a-zA-Z0-9]+)*(?: --[a-zA-Z0-9-=]+)* |)nu" score="1.0" />
</analyse>
</config> </config>
<rules> <rules>
<state name="root"> <state name="root">
@@ -118,4 +123,4 @@
<rule><include state="root" /></rule> <rule><include state="root" /></rule>
</state> </state>
</rules> </rules>
</lexer> </lexer>
@@ -5,6 +5,10 @@
<alias>postscr</alias> <alias>postscr</alias>
<filename>*.ps</filename> <filename>*.ps</filename>
<filename>*.eps</filename> <filename>*.eps</filename>
<filename>*.epsf</filename>
<filename>*.epsi</filename>
<filename>*.pfa</filename>
<filename>*.t42</filename>
<mime_type>application/postscript</mime_type> <mime_type>application/postscript</mime_type>
</config> </config>
<rules> <rules>
@@ -86,4 +90,4 @@
</rule> </rule>
</state> </state>
</rules> </rules>
</lexer> </lexer>
@@ -7,7 +7,7 @@
</config> </config>
<rules> <rules>
<state name="package"> <state name="package">
<rule pattern="[a-zA-Z_]\w*"> <rule pattern="[a-zA-Z_][\w.]*">
<token type="NameNamespace"/> <token type="NameNamespace"/>
<pop depth="1"/> <pop depth="1"/>
</rule> </rule>
@@ -16,7 +16,7 @@
</rule> </rule>
</state> </state>
<state name="message"> <state name="message">
<rule pattern="[a-zA-Z_]\w*"> <rule pattern="[a-zA-Z_][\w.]*">
<token type="NameClass"/> <token type="NameClass"/>
<pop depth="1"/> <pop depth="1"/>
</rule> </rule>
@@ -34,7 +34,7 @@
</rule> </rule>
</state> </state>
<state name="root"> <state name="root">
<rule pattern="[ \t]+"> <rule pattern="\s+">
<token type="Text"/> <token type="Text"/>
</rule> </rule>
<rule pattern="[,;{}\[\]()&lt;&gt;]"> <rule pattern="[,;{}\[\]()&lt;&gt;]">
@@ -46,9 +46,12 @@
<rule pattern="/(\\\n)?\*(.|\n)*?\*(\\\n)?/"> <rule pattern="/(\\\n)?\*(.|\n)*?\*(\\\n)?/">
<token type="CommentMultiline"/> <token type="CommentMultiline"/>
</rule> </rule>
<rule pattern="\b(extensions|required|repeated|optional|returns|default|option|packed|import|ctype|oneof|max|rpc|to)\b"> <rule pattern="\b(ctype|default|edition|export|local|max|option|optional|packed|public|repeated|required|reserved|returns|stream|syntax|to|weak)\b">
<token type="Keyword"/> <token type="Keyword"/>
</rule> </rule>
<rule pattern="\b(extensions|map)\b">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="(sfixed32|sfixed64|fixed32|fixed64|sint32|sint64|double|string|uint32|uint64|int32|float|int64|bytes|bool)\b"> <rule pattern="(sfixed32|sfixed64|fixed32|fixed64|sint32|sint64|double|string|uint32|uint64|int32|float|int64|bytes|bool)\b">
<token type="KeywordType"/> <token type="KeywordType"/>
</rule> </rule>
@@ -62,6 +65,9 @@
</bygroups> </bygroups>
<push state="package"/> <push state="package"/>
</rule> </rule>
<rule pattern="import\b">
<token type="KeywordNamespace"/>
</rule>
<rule pattern="(message|extend)(\s+)"> <rule pattern="(message|extend)(\s+)">
<bygroups> <bygroups>
<token type="KeywordDeclaration"/> <token type="KeywordDeclaration"/>
@@ -69,7 +75,7 @@
</bygroups> </bygroups>
<push state="message"/> <push state="message"/>
</rule> </rule>
<rule pattern="(enum|group|service)(\s+)"> <rule pattern="(enum|group|oneof|rpc|service)(\s+)">
<bygroups> <bygroups>
<token type="KeywordDeclaration"/> <token type="KeywordDeclaration"/>
<token type="Text"/> <token type="Text"/>
@@ -115,4 +121,4 @@
</rule> </rule>
</state> </state>
</rules> </rules>
</lexer> </lexer>
@@ -12,7 +12,11 @@
<filename>*.rbx</filename> <filename>*.rbx</filename>
<filename>*.duby</filename> <filename>*.duby</filename>
<filename>Gemfile</filename> <filename>Gemfile</filename>
<filename>*.gemfile</filename>
<filename>Vagrantfile</filename> <filename>Vagrantfile</filename>
<filename>Appraisals</filename>
<filename>.pryrc</filename>
<filename>*.json.jbuilder</filename>
<mime_type>text/x-ruby</mime_type> <mime_type>text/x-ruby</mime_type>
<mime_type>application/x-ruby</mime_type> <mime_type>application/x-ruby</mime_type>
<dot_all>true</dot_all> <dot_all>true</dot_all>
@@ -0,0 +1,315 @@
<lexer>
<config>
<name>Templ</name>
<alias>templ</alias>
<filename>*.templ</filename>
<mime_type>text/x-templ</mime_type>
<dot_all>true</dot_all>
<analyse>
<regex pattern="(?m)^\s*templ\s+[A-Za-z_]\w*\s*\(" score="0.7"/>
<regex pattern="(?m)^\s*package\s+\w+[\s\S]*^\s*templ\s+" score="0.5"/>
</analyse>
</config>
<rules>
<state name="root">
<rule pattern="//[^\n\r]*">
<token type="CommentSingle"/>
</rule>
<rule pattern="/\*(?:.|\n)*?\*/">
<token type="CommentMultiline"/>
</rule>
<rule pattern="(?m)^(\s*)(package|import|const|type|func)([^\n]*)">
<bygroups>
<token type="TextWhitespace"/>
<using lexer="Go"/>
<using lexer="Go"/>
</bygroups>
</rule>
<rule pattern="\b(templ|css|script)(\s+)([A-Za-z_]\w*)(\s*)(\([^{}]*\))(\s*)({)">
<bygroups>
<token type="KeywordDeclaration"/>
<token type="TextWhitespace"/>
<token type="NameFunction"/>
<token type="TextWhitespace"/>
<using lexer="Go"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="@[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\([^{}()\r\n]*(?:\([^{}()\r\n]*\)[^{}()\r\n]*)*\))?(?:[ \t]*{)?">
<token type="NameFunction"/>
</rule>
<rule pattern="(?m)^(\s*)(if|for|switch|select)(\s+)([^{}\n]*)(\s*)({)">
<bygroups>
<token type="TextWhitespace"/>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<using lexer="Go"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="(?m)^(\s*)(else)(\s*)(if)?(\s*)([^{}\n]*)(\s*)({)?">
<bygroups>
<token type="TextWhitespace"/>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<using lexer="Go"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="(})(\s*)(else)(\s*)(if)?(\s*)([^{}\n]*)(\s*)({)?">
<bygroups>
<token type="Punctuation"/>
<token type="TextWhitespace"/>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<using lexer="Go"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="({)([^{}\n]*)(})">
<bygroups>
<token type="Punctuation"/>
<using lexer="Go"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="}">
<token type="Punctuation"/>
</rule>
<rule pattern="`(?:.|\n)*?`">
<token type="LiteralStringBacktick"/>
</rule>
<rule pattern="\s+">
<token type="TextWhitespace"/>
</rule>
<rule pattern="[^&lt;&amp;@{}`\s]+">
<token type="Text"/>
</rule>
<rule pattern="&amp;\S*?;">
<token type="NameEntity"/>
</rule>
<rule pattern="\&lt;\!\[CDATA\[.*?\]\]\&gt;">
<token type="CommentPreproc"/>
</rule>
<rule pattern="&lt;!--">
<token type="Comment"/>
<push state="comment"/>
</rule>
<rule pattern="&lt;\?.*?\?&gt;">
<token type="CommentPreproc"/>
</rule>
<rule pattern="&lt;![^&gt;]*&gt;">
<token type="CommentPreproc"/>
</rule>
<rule pattern="(&lt;)(script)(\s*)">
<bygroups>
<token type="Punctuation"/>
<token type="NameTag"/>
<token type="Text"/>
</bygroups>
<push state="script-content" state="tag"/>
</rule>
<rule pattern="(&lt;)(style)(\s*)">
<bygroups>
<token type="Punctuation"/>
<token type="NameTag"/>
<token type="Text"/>
</bygroups>
<push state="style-content" state="tag"/>
</rule>
<rule pattern="(&lt;)([A-Za-z][\w:.-]*)">
<bygroups>
<token type="Punctuation"/>
<token type="NameTag"/>
</bygroups>
<push state="tag"/>
</rule>
<rule pattern="(&lt;/)([A-Za-z][\w:.-]*)(\s*)(&gt;)">
<bygroups>
<token type="Punctuation"/>
<token type="NameTag"/>
<token type="Text"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="[@{}&lt;&amp;]">
<token type="Punctuation"/>
</rule>
</state>
<state name="script-content">
<rule pattern="(&lt;)(\s*)(/)(\s*)(script)(\s*)(&gt;)">
<bygroups>
<token type="Punctuation"/>
<token type="Text"/>
<token type="Punctuation"/>
<token type="Text"/>
<token type="NameTag"/>
<token type="Text"/>
<token type="Punctuation"/>
</bygroups>
<pop depth="1"/>
</rule>
<rule pattern="\s*[^\r\n]*\{\{.*?\}\}[^\r\n]*">
<token type="Other"/>
</rule>
<rule pattern=".+?(?=&lt;\s*/\s*script\s*&gt;)">
<token type="Other"/>
</rule>
</state>
<state name="style-content">
<rule pattern="(&lt;)(\s*)(/)(\s*)(style)(\s*)(&gt;)">
<bygroups>
<token type="Punctuation"/>
<token type="Text"/>
<token type="Punctuation"/>
<token type="Text"/>
<token type="NameTag"/>
<token type="Text"/>
<token type="Punctuation"/>
</bygroups>
<pop depth="1"/>
</rule>
<rule pattern=".+?(?=&lt;\s*/\s*style\s*&gt;)">
<using lexer="CSS"/>
</rule>
</state>
<state name="comment">
<rule pattern="[^-]+">
<token type="Comment"/>
</rule>
<rule pattern="--&gt;">
<token type="Comment"/>
<pop depth="1"/>
</rule>
<rule pattern="-">
<token type="Comment"/>
</rule>
</state>
<state name="tag">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="//[^\n\r]*">
<token type="CommentSingle"/>
</rule>
<rule pattern="/\*(?:.|\n)*?\*/">
<token type="CommentMultiline"/>
</rule>
<rule pattern="\b(if|for|switch|select)(\s+)([^{}]*)(\s*)({)">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<using lexer="Go"/>
<token type="Text"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="\b(else)(\s*)(if)?(\s*)([^{}]*)(\s*)({)?">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<token type="Keyword"/>
<token type="Text"/>
<using lexer="Go"/>
<token type="Text"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="}">
<token type="Punctuation"/>
</rule>
<rule pattern="({)([^{}]*)(})(\??)(\s*)(=)(\s*)({)([^{}]*)(})">
<bygroups>
<token type="Punctuation"/>
<using lexer="Go"/>
<token type="Punctuation"/>
<token type="Operator"/>
<token type="Text"/>
<token type="Operator"/>
<token type="Text"/>
<token type="Punctuation"/>
<using lexer="Go"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="({)([^{}]*)(})(\??)(\s*)(=)(\s*)">
<bygroups>
<token type="Punctuation"/>
<using lexer="Go"/>
<token type="Punctuation"/>
<token type="Operator"/>
<token type="Text"/>
<token type="Operator"/>
<token type="Text"/>
</bygroups>
<push state="attr"/>
</rule>
<rule pattern="({)([^{}]*)(})(\??)">
<bygroups>
<token type="Punctuation"/>
<using lexer="Go"/>
<token type="Punctuation"/>
<token type="Operator"/>
</bygroups>
</rule>
<rule pattern="({)([^{}]*)(})">
<bygroups>
<token type="Punctuation"/>
<using lexer="Go"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="((?:[@#.][\w:.*-]+|\[[^\]\s=]+\]|[\w:.*-]+\??)\s*)(=)(\s*)({)([^{}]*)(})">
<bygroups>
<token type="NameAttribute"/>
<token type="Operator"/>
<token type="Text"/>
<token type="Punctuation"/>
<using lexer="Go"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="((?:[@#.][\w:.*-]+|\[[^\]\s=]+\]|[\w:.*-]+\??)\s*)(=)(\s*)">
<bygroups>
<token type="NameAttribute"/>
<token type="Operator"/>
<token type="Text"/>
</bygroups>
<push state="attr"/>
</rule>
<rule pattern="(?:[@#.][\w:.*-]+|\[[^\]\s=]+\]|[\w:.*-]+\??)">
<token type="NameAttribute"/>
</rule>
<rule pattern="(/?)(\s*)(&gt;)">
<bygroups>
<token type="Punctuation"/>
<token type="Text"/>
<token type="Punctuation"/>
</bygroups>
<pop depth="1"/>
</rule>
</state>
<state name="attr">
<rule pattern="&#34;.*?&#34;">
<token type="LiteralString"/>
<pop depth="1"/>
</rule>
<rule pattern="&#39;.*?&#39;">
<token type="LiteralString"/>
<pop depth="1"/>
</rule>
<rule pattern="[^\s&gt;]+">
<token type="LiteralString"/>
<pop depth="1"/>
</rule>
</state>
</rules>
</lexer>
+14 -4
View File
@@ -23,6 +23,9 @@
<rule pattern="#.*$"> <rule pattern="#.*$">
<token type="Comment"/> <token type="Comment"/>
</rule> </rule>
<rule pattern="//.*$">
<token type="Comment"/>
</rule>
<rule pattern="!![^\s]+"> <rule pattern="!![^\s]+">
<token type="CommentPreproc"/> <token type="CommentPreproc"/>
</rule> </rule>
@@ -78,15 +81,22 @@
<token type="Comment"/> <token type="Comment"/>
</bygroups> </bygroups>
</rule> </rule>
<rule pattern="([^\{\}\[\]\?,\:\!\-\*&amp;\@].*)( )+(//.*)">
<bygroups>
<token type="Literal"/>
<token type="TextWhitespace"/>
<token type="Comment"/>
</bygroups>
</rule>
<rule pattern="[^\{\}\[\]\?,\:\!\-\*&amp;\@].*"> <rule pattern="[^\{\}\[\]\?,\:\!\-\*&amp;\@].*">
<token type="Literal"/> <token type="Literal"/>
</rule> </rule>
</state> </state>
<state name="key"> <state name="key">
<rule pattern="&#34;[^&#34;\n].*&#34;: "> <rule pattern="&#34;[^&#34;\n#].*&#34;: ">
<token type="NameTag"/> <token type="NameTag"/>
</rule> </rule>
<rule pattern="(-)( )([^&#34;\n{]*)(:)( )"> <rule pattern="(-)( )((?:(?!//)[^&#34;\n{#])*?)(:)( )">
<bygroups> <bygroups>
<token type="Punctuation"/> <token type="Punctuation"/>
<token type="TextWhitespace"/> <token type="TextWhitespace"/>
@@ -95,14 +105,14 @@
<token type="TextWhitespace"/> <token type="TextWhitespace"/>
</bygroups> </bygroups>
</rule> </rule>
<rule pattern="([^&#34;\n{]*)(:)( )"> <rule pattern="((?:(?!//)[^&#34;\n{#])*?)(:)( )">
<bygroups> <bygroups>
<token type="NameTag"/> <token type="NameTag"/>
<token type="Punctuation"/> <token type="Punctuation"/>
<token type="TextWhitespace"/> <token type="TextWhitespace"/>
</bygroups> </bygroups>
</rule> </rule>
<rule pattern="([^&#34;\n{]*)(:)(\n)"> <rule pattern="((?:(?!//)[^&#34;\n{#])*?)(:)(\n)">
<bygroups> <bygroups>
<token type="NameTag"/> <token type="NameTag"/>
<token type="Punctuation"/> <token type="Punctuation"/>
+2 -2
View File
@@ -30,8 +30,8 @@ func goRules() Rules {
"root": { "root": {
{`\n`, TextWhitespace, nil}, {`\n`, TextWhitespace, nil},
{`\s+`, TextWhitespace, nil}, {`\s+`, TextWhitespace, nil},
{`//[^\s][^\n\r]*`, CommentPreproc, nil}, {`//[^\s\n\r][^\n\r]*`, CommentPreproc, nil},
{`//\s+[^\n\r]*`, CommentSingle, nil}, {`//[^\n\r]*`, CommentSingle, nil},
{`/(\\\n)?[*](.|\n)*?[*](\\\n)?/`, CommentMultiline, nil}, {`/(\\\n)?[*](.|\n)*?[*](\\\n)?/`, CommentMultiline, nil},
{`(import|package)\b`, KeywordNamespace, nil}, {`(import|package)\b`, KeywordNamespace, nil},
{`(var|func|struct|map|chan|type|interface|const)\b`, KeywordDeclaration, nil}, {`(var|func|struct|map|chan|type|interface|const)\b`, KeywordDeclaration, nil},
+1 -1
View File
@@ -122,7 +122,7 @@ func (d *httpBodyContentTyper) Tokenise(options *TokeniseOptions, text string) (
if err != nil { if err != nil {
panic(err) panic(err)
} }
return EOF return subIterator()
} }
} }
} }
+64 -3
View File
@@ -1,11 +1,13 @@
package lexers package lexers
import ( import (
"strings"
. "github.com/alecthomas/chroma/v2" // nolint . "github.com/alecthomas/chroma/v2" // nolint
) )
// Markdown lexer. // Markdown lexer with YAML frontmatter and HTML comment support.
var Markdown = Register(MustNewLexer( var Markdown = Register(&markdownLexer{Lexer: MustNewLexer(
&Config{ &Config{
Name: "markdown", Name: "markdown",
Aliases: []string{"md", "mkd"}, Aliases: []string{"md", "mkd"},
@@ -13,11 +15,69 @@ var Markdown = Register(MustNewLexer(
MimeTypes: []string{"text/x-markdown"}, MimeTypes: []string{"text/x-markdown"},
}, },
markdownRules, markdownRules,
)) )})
// markdownLexer wraps the base Markdown lexer to highlight top-of-file YAML frontmatter.
type markdownLexer struct {
Lexer
}
// Lexes Markdown, highlighting a leading YAML frontmatter block before delegating to Markdown rules.
func (m *markdownLexer) Tokenise(options *TokeniseOptions, text string) (Iterator, error) {
frontmatter, rest, ok := splitFrontmatter(text)
if !ok {
return m.Lexer.Tokenise(options, text)
}
yamlLexer := Get("YAML")
if yamlLexer == nil {
return m.Lexer.Tokenise(options, text)
}
yamlTokens, err := yamlLexer.Tokenise(options, frontmatter)
if err != nil {
return nil, err
}
markdownTokens, err := m.Lexer.Tokenise(options, rest)
if err != nil {
return nil, err
}
return Concaterator(yamlTokens, markdownTokens), nil
}
// Extracts a leading YAML frontmatter block if the document starts with one.
func splitFrontmatter(text string) (frontmatter string, rest string, ok bool) {
if !strings.HasPrefix(text, "---\n") && !strings.HasPrefix(text, "---\r\n") {
return "", text, false
}
lineEnd := strings.IndexByte(text, '\n')
if lineEnd < 0 {
return "", text, false
}
if strings.TrimSuffix(text[:lineEnd], "\r") != "---" {
return "", text, false
}
for pos := lineEnd + 1; pos < len(text); {
next := strings.IndexByte(text[pos:], '\n')
if next < 0 {
break
}
lineEnd = pos + next
line := strings.TrimSuffix(text[pos:lineEnd], "\r")
if line == "---" {
return text[:lineEnd+1], text[lineEnd+1:], true
}
pos = lineEnd + 1
}
return "", text, false
}
func markdownRules() Rules { func markdownRules() Rules {
return Rules{ return Rules{
"root": { "root": {
{`<!--[\w\W]*?-->`, CommentMultiline, nil},
{`^(#[^#].+\n)`, ByGroups(GenericHeading), nil}, {`^(#[^#].+\n)`, ByGroups(GenericHeading), nil},
{`^(#{2,6}.+\n)`, ByGroups(GenericSubheading), nil}, {`^(#{2,6}.+\n)`, ByGroups(GenericSubheading), nil},
{`^(\s*)([*-] )(\[[ xX]\])( .+\n)`, ByGroups(Text, Keyword, Keyword, UsingSelf("inline")), nil}, {`^(\s*)([*-] )(\[[ xX]\])( .+\n)`, ByGroups(Text, Keyword, Keyword, UsingSelf("inline")), nil},
@@ -33,6 +93,7 @@ func markdownRules() Rules {
Include("inline"), Include("inline"),
}, },
"inline": { "inline": {
{`<!--[\w\W]*?-->`, CommentMultiline, nil},
{`\\.`, Text, nil}, {`\\.`, Text, nil},
{`(\s)(\*|_)((?:(?!\2).)*)(\2)((?=\W|\n))`, ByGroups(Text, GenericEmph, GenericEmph, GenericEmph, Text), nil}, {`(\s)(\*|_)((?:(?!\2).)*)(\2)((?=\W|\n))`, ByGroups(Text, GenericEmph, GenericEmph, GenericEmph, Text), nil},
{`(\s)((\*\*|__).*?)\3((?=\W|\n))`, ByGroups(Text, GenericStrong, GenericStrong, Text), nil}, {`(\s)((\*\*|__).*?)\3((?=\W|\n))`, ByGroups(Text, GenericStrong, GenericStrong, Text), nil},

Some files were not shown because too many files have changed in this diff Show More