Compare commits

...
Author SHA1 Message Date
Dmitry Verkhoturov 8d9d9e3709 Pin the public path #2203 fixed, which shipped without a test
The bundler used to bake a fixed public path into every entry, so an
instance mounted under a prefix, which manuals/subdomain documents,
asked the domain root for its provider icons and got nothing. #2197
derived the path from the URL the bundle was loaded from instead, and
covered it with nothing: publicPath appears only in webpack.config.js
and neither suite touched asset paths.

The check is on the assignment webpack emits for its runtime public
path, a string literal when the path is fixed and an expression when it
is derived. Asset filenames are bare in both builds, so the obvious
assertion, looking for a rooted "/web/name.svg" string, finds nothing
either way and proves nothing. I wrote that one first and it passed
against a deliberately broken build.

Every emitted bundle is checked rather than the entry the bug was
reported against: it put fifteen icons in remark.mjs and one in
last-comments.mjs, so a case reading a single entry would have gone
green with half of it still live.

Verified by rebuilding the frontend with the public path baked back in:
the assertion fails on that build and passes on master's.
2026-08-23 13:51:54 +01: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
UmputunandGitHub 556e0a70d5 chore(release): build binary artifacts with GoReleaser (#2070)
replace the Docker artifact build with GoReleaser config and a tag release workflow. Keep local artifact builds snapshot-only and clean generated frontend embed files after release runs.
2026-05-22 13:24:32 -05:00
Dmitry VerkhoturovandGitHub 0e20861419 fix(security): reject non-image content-types in image proxy and /picture/ to prevent stored XSS (#2067)
* fix(security): reject non-image content-types in image proxy and /picture/ to prevent stored XSS

The /api/v1/img proxy and /api/v1/picture/{user}/{id} endpoints emitted
http.DetectContentType on the served bytes as the response Content-Type. A
controlled upstream serving Content-Type: image/png with an HTML body passed
the upstream check (only the response header was inspected, not the body),
and the body bytes then sniffed back to text/html — so the proxy served the
attacker's HTML from the remark42 origin. Browsers honoured the declared
text/html and executed the response as a document with access to cookies and
CSRF tokens. Affected from v1.6.0 (April 2020) through v1.15.0; verified live
via published docker images.

Layered defense applied to both handlers:

- rest.SafeImgContentType (in backend/app/rest/) validates sniffed content
  against a strict allowlist: image/png, image/jpeg, image/gif, image/webp,
  image/bmp, image/x-icon. Anything else (HTML, XML, SVG, plain text,
  octet-stream, or any future image type the stdlib sniffer may learn) is
  rejected with no body echo. SVG is implicitly excluded — it sniffs as
  text/xml or text/plain, never image/svg+xml, and SVG can execute scripts
  when navigated to top-level. The previous octet-stream → image/* fallback
  is gone.
- Per-endpoint Content-Security-Policy override sets
  "default-src 'none'; sandbox; frame-ancestors 'none'" on every response
  (success, 304, or error). Sandbox neuters scripts even if Content-Type
  ever regresses. The same policy is also applied to all /api/v1/* via
  apiCSPMiddleware as defense-in-depth.
- Content-Disposition: inline; filename="image" frames the response as a
  file rather than a renderable document.
- /picture/ rejection paths set Cache-Control: no-store so 4xx responses
  are never cached.

The defense headers and the strict ETag matcher are extracted as
rest.SetImageDefenseHeaders and rest.EtagMatches in the shared rest package
(consumed by both proxy/image and api/rest_public — no package cycle).

The /api/v1/img path additionally bumps the ETag to a versioned `"v2:..."`
so revalidating clients (top-level navigation, Ctrl+R, intermediaries) get
a fresh 200 instead of a 304 against poisoned pre-fix cached HTML.

DELIBERATE TRADEOFF: Cache-Control on /api/v1/img success responses remains
max-age=2592000 (30 days), unchanged from before. An aggressive "force
revalidate on every reuse" policy was prototyped during review but reverted
because the perf cost (a server round-trip on every image view, even with
304 saving the body bytes) outweighed the corner-case mitigation. The
realistic exposure of cache carryover is narrow: cache carryover only
affects users who navigated top-level to an attacker URL pre-fix and still
have it in their local cache — the normal <img> embed path cached text/html
but never executed it. Local browser caches that hold pre-fix bytes
continue to serve them until their 30-day TTL expires or are evicted under
memory pressure. The ETag bump reaches all clients that DO revalidate
during the cached lifetime (Ctrl+R, intermediaries, post-expiry use); for
the rest, exposure self-limits via cache expiry. Operators running a
CDN/edge cache in front of remark42 should purge /api/v1/img after deploy.

The /api/v1/img handler short-circuits on a matching current-version
If-None-Match before any store Load or upstream fetch, returning a bodyless
304 with the defense headers set. Safe because the 304 carries no body and
the client's cached bytes came from a prior validated 200; an attacker
fabricating an etag value can only short-circuit fetches for URLs they
themselves crafted. This avoids upstream DoS amplification when clients
revalidate on hot comment pages.

The /api/v1/img route was moved from the "open routes" group (which uses
middleware.NoCache, stripping If-None-Match from incoming requests) to the
"open routes, cached" group alongside /picture/ and /qr/telegram so the
304 revalidation path is no longer broken upstream of the handler.

The /picture/{user}/{id} endpoint does not need the v2 etag prefix. Upload
validates input format via readAndValidateImage and the serve path
re-validates the stored bytes via rest.SafeImgContentType. Bytes within
the resize dimension limits are preserved verbatim, so the browser defense
relies on the response headers (validated Content-Type + nosniff + strict
CSP + Content-Disposition: inline), not on byte normalization.

Global CSP: font-src data: → font-src 'none'. Audit confirmed no @font-face,
no base64 fonts, no icon-font library in the bundle. Drops an unnecessary
attack surface; no behavioural change.

Tests: TestImage_ContentTypeHandling table-tests a real PNG and attack
shapes (HTML claimed as image/png, image/jpeg, image/gif, image/svg+xml,
image/webp; svg with onload; html fragment; polyglot PNG+HTML), proving
the defense holds across arbitrary upstream Content-Type variation.
Polyglot case is intentionally served as image/png — the browser cannot
execute the trailing HTML when the response type is image/png with nosniff.
TestImage_ContentTypeHandling_CacheHit exercises the cache-hit branch with
attacker bytes preloaded into the store. TestImage_PerRequestRevalidation
alternates upstream PNG/HTML across four proxy calls to prove no trust
accumulates between requests. TestImage_RoutesUsingCachedImage asserts
cache-poisoning is caught at serve time. TestImage_EtagVersioned asserts
the v2 prefix invalidates pre-fix etags AND that the revalidation 304
triggers no store Load. TestImage_RevalidationSkipsIO proves the
short-circuit works even with no upstream reachable. TestSafeImgContentType
covers the allowlist directly. TestRest_LoadPictureDefenseHeaders and
TestRest_LoadPictureRejectsNonImage exercise the /picture/ endpoint.
TestRest_apiCSP covers the strict CSP middleware on JSON API + RSS routes;
TestRest_securityHeaders confirms /web/ HTML pages keep the global CSP.

Verified end-to-end against the dev docker image: the original demo URL
(arbitrary HTML claimed as image/png) now returns 415 application/json with
CSP/nosniff/Content-Disposition set, no XSS in the browser.

* fix(security): set Cache-Control: no-store on image-proxy error paths, sync stale route comment

Addresses two review comments on #2067:

1. Cache-Control: max-age=2592000 and Etag were set before the
   load/download/validation block, so 404/400/415 error responses inherited
   the 30-day cache TTL and the versioned etag — a transient failure (or an
   intentionally triggered 415) would be pinned in browser/intermediary
   caches for that TTL, keeping users locked out even after the underlying
   cause was resolved. Now: etag is computed but not set as a header until
   after validation succeeds; error paths route through sendImageProxyError
   which sets Cache-Control: no-store and never sets Etag. The 304
   short-circuit still sets both because that path serves the same validated
   content the client already has cached.

2. The comment at rest.go:282 still described the prototyped
   no-cache/must-revalidate Cache-Control policy that was reverted before
   the PR landed. Updated to match the actual 30-day max-age behavior.

Tests: TestImage_ContentTypeHandling now asserts reject paths carry
Cache-Control: no-store and have no Etag header, and accept paths carry
the max-age=2592000 + v2: etag.
2026-05-20 22:37:25 -05:00
Dmitry VerkhoturovandUmputun 8224626ed4 fix(image): reject decompression-bomb dimensions before raster decode
readAndValidateImage caps the byte size of incoming images but the resize()
helper that follows still called image.Decode unconditionally, allocating
pixel memory proportional to the *declared* image dimensions. A ~100 KB
compressed PNG or GIF that declares 65535x65535 px forces image.Decode to
allocate ~17 GB of raster, OOMing the service on a single comment upload
(or on the proxy's CacheExternal path when caching a malicious upstream).

Hardening:

- maxImagePixels = 16 MP constant. Covers any realistic image (~4096x4096)
  while bounding peak allocation.
- resize() now runs image.DecodeConfig first (cheap, no pixel allocation)
  to read declared width/height before any full decode.
- Multiplication of width × height uses int64 to defeat 32-bit overflow
  (GOARCH=386, 32-bit arm): on those targets, int(cfg.Width)*int(cfg.Height)
  could wrap below maxImagePixels and bypass the cap. GIF's 16-bit logical
  screen and JPEG's 16-bit SOF dimensions both reach this if int-multiplied.
- Bytes exceeding the cap, or non-image input that fails DecodeConfig,
  return nil. prepareImage propagates the rejection as a clear error
  instead of storing the malformed/oversized data verbatim.
- The no-resize-needed path returns the validated original bytes verbatim
  so animated GIFs round-trip without being flattened to a single frame.

The DecodeConfig precheck applies even when MaxWidth/MaxHeight are 0
(resize disabled) — the dimension cap is unconditional defense-in-depth.

Two adjacent fixes surfaced by the new resize contract:

1. readAndValidateImage previously did `data[:512]` without a bounds check,
   panicking on any body shorter than 512 bytes. Now bounded with min().
2. image/webp was listed as an allowed format but no WebP decoder was
   registered, so DecodeConfig would refuse legitimate WebP uploads. Added
   `_ "golang.org/x/image/webp"` (already in go.mod via x/image/draw) so
   the registered decoders match the allowlist.

Tests:

- TestService_resizeRejectsDecompressionBomb builds a 14-byte GIF87a header
  declaring 65535x65535 and asserts resize() refuses it both at the unit
  level and through SaveWithID end-to-end (no store write).
- TestService_SaveWithIDShortPayload regression-tests the short-body panic.
- TestService_SaveWithIDWebP regression-tests WebP round-trip through
  prepareImage with the new DecodeConfig requirement.
- TestService_resize subtests updated to assert non-image bytes are now
  refused (previously the helper fell back to returning the raw bytes
  verbatim, letting malformed content reach the store).
2026-05-20 21:48:23 -05:00
Dmitry VerkhoturovandUmputun 45c17a913f chore(deps): bump go modules in backend and example
Backend (backend/go.mod):
- github.com/go-pkgz/auth/v2 v2.1.2 → v2.1.4
- github.com/klauspost/compress v1.18.5 → v1.18.6
- github.com/redis/go-redis/v9 v9.18.0 → v9.19.0
- github.com/slack-go/slack v0.21.1 → v0.23.1
- golang.org/x/crypto v0.50.0 → v0.51.0
- golang.org/x/image v0.39.0 → v0.40.0
- golang.org/x/net v0.53.0 → v0.54.0
- golang.org/x/sys v0.43.0 → v0.44.0
- golang.org/x/text v0.36.0 → v0.37.0

Example (backend/_example/memory_store/go.mod):
- golang.org/x/crypto v0.50.0 → v0.51.0
- golang.org/x/image v0.39.0 → v0.40.0
- golang.org/x/net v0.53.0 → v0.54.0
- golang.org/x/sys v0.43.0 → v0.44.0

Transitive cleanup: github.com/dgryski/go-rendezvous is no longer required
after redis/go-redis bump and gets pruned by `go mod tidy`.

`go mod tidy` + `go mod vendor` run on both modules. Both build with -race
and full test suites pass.
2026-05-20 20:09:47 -05:00
Umputun f3a7dea1f1 docs: offer github private vulnerability reporting in security policy
Mention the "Report a vulnerability" button (GitHub private vulnerability
reporting) alongside the existing email contact, now that private reporting
is enabled on the repository.
2026-05-20 13:41:06 -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
UmputunandGitHub c0636f204e Merge pull request #2053 from umputun/dependabot/github_actions/github-actions-updates-512a575e1a
chore(deps): bump pnpm/action-setup from 5.0.0 to 6.0.4 in the github-actions-updates group
2026-05-06 01:53:15 -05:00
UmputunandGitHub c418f8ec00 Merge pull request #2052 from umputun/dependabot/go_modules/backend/go-modules-updates-47fdc5c9f4
chore(deps): bump the go-modules-updates group in /backend with 2 updates
2026-05-06 01:53:10 -05:00
dependabot[bot]andDmitry Verkhoturov e9ad5dcc09 chore(deps): bump the go-modules-updates group
Bumps the go-modules-updates group in /backend with 2 updates: [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) and [github.com/go-pkgz/auth/v2](https://github.com/go-pkgz/auth).

Updates `github.com/alecthomas/chroma/v2` from 2.23.1 to 2.24.1
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.23.1...v2.24.1)

Updates `github.com/go-pkgz/auth/v2` from 2.1.2-0.20260421203319-686683f19cf7 to 2.1.2
- [Release notes](https://github.com/go-pkgz/auth/releases)
- [Commits](https://github.com/go-pkgz/auth/commits/v2.1.2)

---
updated-dependencies:
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/auth/v2
  dependency-version: 2.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 22:52:12 +01:00
dependabot[bot]andGitHub d072f34a67 chore(deps): bump pnpm/action-setup in the github-actions-updates group
Bumps the github-actions-updates group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


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

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.4
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 13:46:19 +00:00
Dmitry VerkhoturovandGitHub a4c5e17bbb Probe /auth/status from frontend to avoid 401 on /user (closes #1188) (#1763)
* Probe /auth/status from frontend to avoid 401 console noise on /user

GET /api/v1/user requires auth and returns 401 for anonymous visitors,
which the browser logs to console even when JS catches it. Probe
/auth/status first (always 200), then fetch /user only when logged in.
Stale auth cookies are cleared when status reports "not logged in" to
preserve the cleanup-on-probe behaviour previously triggered by /user 401.

Closes #1188.

* Don't clear auth cookies when /auth/status probe itself fails

A transient network/5xx on the /auth/status probe used to fall through
into the cookie-clear branch and silently log the user out on the next
page load. Distinguish "probe failed" (null) from explicit "not logged
in"; only the latter clears JWT/XSRF cookies. Lock the distinction with
a negative assertion in the probe-failure test.

Also align packages/api prettier config with apps/remark42 (trailingComma: 'es5')
so future edits don't sweep unrelated trailing commas into the diff.
2026-04-30 19:32:49 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8d9290ea1f Bump picomatch from 2.3.1 to 2.3.2 in /site (#2028)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:29:44 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
07d89202b4 chore(deps): bump liquidjs from 10.19.0 to 10.25.6 in /site (#2050)
Bumps [liquidjs](https://github.com/harttle/liquidjs) from 10.19.0 to 10.25.6.
- [Release notes](https://github.com/harttle/liquidjs/releases)
- [Changelog](https://github.com/harttle/liquidjs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harttle/liquidjs/compare/v10.19.0...v10.25.6)

---
updated-dependencies:
- dependency-name: liquidjs
  dependency-version: 10.25.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:29:41 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5d6599237d Bump the github-actions-updates group across 1 directory with 7 updates (#2034)
Bumps the github-actions-updates group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `4.2.0` | `5.0.0` |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `5` | `6` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `6` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `7` | `8` |



Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `pnpm/action-setup` from 4.2.0 to 5.0.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4.2.0...v5.0.0)

Updates `codecov/codecov-action` from 5 to 6
- [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/v5...v6)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

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

Updates `actions/download-artifact` from 7 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: pnpm/action-setup
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:29:38 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b13b737461 Bump handlebars from 4.7.8 to 4.7.9 in /site (#2030)
Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.8 to 4.7.9.
- [Release notes](https://github.com/handlebars-lang/handlebars.js/releases)
- [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.9/release-notes.md)
- [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...v4.7.9)

---
updated-dependencies:
- dependency-name: handlebars
  dependency-version: 4.7.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:29:27 -05:00
Dmitry VerkhoturovandGitHub c9ba8520c7 fix(auth): close OAuth open-redirect by wiring AllowedRedirectHosts (#2049)
* fix(auth): close OAuth open-redirect by wiring AllowedRedirectHosts

Bump go-pkgz/auth/v2 to master (v2.1.2-0.20260421203319-686683f19cf7)
which carries the `from` redirect validator from go-pkgz/auth#275.

The library default with a nil AllowedRedirectHosts is permissive
(preserves legacy behavior for existing consumers on a dep bump), so
just bumping the dep leaves remark42 vulnerable — a crafted
/auth/<provider>/login?from=https://evil.example.com/... still issues
the 307 to the attacker host after the user completes legitimate
OAuth. Verified end-to-end against a local dev-auth instance before
and after this commit.

Wire Opts.AllowedRedirectHosts in getAuthenticator to the operator's
existing --allowed-hosts config, stripping the CSP "self" sentinel
which is not a real hostname. RemarkURL's own host is always implicit
per the library contract, so a default single-site deployment gains
the protection with no config change. Multi-host embeds work as soon
as their embedding hosts are added to AllowedHosts (they already need
to be there for CSP frame-ancestors).

Refreshed vendor tree to match the new module version.

* chore(lint): suppress G703 false positives on image Save

CI's newer gosec flags os.MkdirAll/os.WriteFile in FileSystem.Save with
G703 because id flows in from the caller. id is validated at the HTTP
layer (safePictureSegment in rest_public.go) and dst is derived via
f.location — not a real traversal. Targeted //nolint with reason.

* fix(auth): normalise AllowedRedirectHosts entries + add unit test

Address Copilot review on PR #2049. The previous closure passed raw
s.AllowedHosts entries straight to the auth library, but --allowed-hosts
holds CSP frame-ancestors source expressions: scheme-prefixed values
(https://blog.example.com), entries with ports, and wildcards
(*.cdn.example.com) are all valid there but the auth library compares
against u.Hostname() and would silently drop them — breaking legitimate
redirects on multi-host deployments.

Extract getAllowedRedirectHosts that:
* trims whitespace, drops empty / 'self' / "self" / wildcard entries
* prepends https:// if scheme missing then url.Parse to extract Hostname
* logs a warning on parse failure rather than poisoning the allowlist

Wire the closure in getAuthenticator to call the helper.

Test_getAllowedRedirectHosts covers all the edge cases Copilot flagged
(scheme stripping, port handling, self spellings, wildcards, empty,
mixed real-world).

* fix(auth): preserve explicit port in AllowedRedirectHosts + clarify fs_store nolint

Address Copilot follow-up on PR #2049:

* getAllowedRedirectHosts stripped explicit ports via u.Hostname(), which
  broadened the allowlist. The auth validator checks both Hostname() and
  Host, so an entry like admin.example.com:8443 can and should be kept
  host:port — allowing only that port, not any. Emit u.Host when
  u.Port() != "", u.Hostname() otherwise. Updated tests.

* fs_store Save nolint rationale said "id validated at HTTP layer", but
  Save is reached via image.Service.Save and SaveWithID (cache), neither
  of which is HTTP validation. id is actually a server-generated hash in
  both paths. Updated the comment.
2026-04-21 19:09:26 -05:00
Dmitry VerkhoturovGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>umputun
ee782785f0 test: use testing/synctest to eliminate wall-clock sleeps (#2048)
Go 1.25's testing/synctest package (GA) provides a fake clock bubble
for deterministic goroutine and timer testing. Convert tests that
waited on real-time durations to use synctest, removing most wall-clock
time.Sleep workarounds.

Converted (11 tests, 9 files):
- notify/notify_test.go — all tests, replaced 17 time.Sleep(110ms) with synctest.Wait()
- store/service/service_test.go — VoteSameIPWithDuration, UserReplies, submitImages,
  ResubmitStagingImages, deleteImagesOnCommentDelete
- store/image/{image,bolt_store}_test.go — Cleanup, Submit, SubmitDelay
- store/engine/bolt_test.go — FlagListBlocked
- providers/telegram_test.go — DispatchTelegramUpdates
- migrator/backup_test.go — TestBackup_Do
- _example/memory_store/accessor/data_test.go — FlagListBlocked

Simplifications along the way:
- notify/notify_mock.go: dropped the 10ms time.After delay and
  ctx.Done select in MockDest — the artificial I/O simulation is
  pointless and blocked synctest.Wait from draining the queue
- Removed three dead-code time.Sleep(1s) calls in EditCommentDurationFailed,
  EditCommentAdmin, and Info tests: prepopulated comments from 2017
  already exceed any EditDuration/ReadOnlyAge under real clock, making
  the sleeps meaningless
- UserReplies: replaced the Eventually+Sleep+mutex polling with a
  direct time.Sleep under fake clock

Skipped (incompatible with synctest):
- fs_store_test.go: relies on OS file mtime (real wall clock)
- rss_test.go: needs real wall-clock second boundary for pubDate
- admin/rest_private/rest_public tests: httptest network I/O
- cmd/server_test.go: real HTTP server startup polling

Notes on quirks encountered:
- synctest.Wait() does NOT advance fake time, contrary to what one
  might expect. It only returns once all other bubble goroutines are
  durably blocked. To advance the fake clock, the test goroutine must
  itself call time.Sleep
- BoltDB keys the "last" bucket by comment.Timestamp nanosecond string.
  Rapid b.Create calls under frozen fake time produce identical keys
  and overwrite each other. TestService_UserReplies adds
  time.Sleep(time.Nanosecond) between Creates to advance the clock
- Bolt image Cleanup uses strict age > ttl. Under fake time the
  age-ttl delta is exactly zero at the boundary, so subtract 1ms from
  the passed ttl to stay strictly under

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: umputun <535880+umputun@users.noreply.github.com>
2026-04-18 02:44:21 -05:00
Dmitry VerkhoturovandUmputun 3b1d7be6fc fix(safehttp): clone http.DefaultTransport, sharpen Image.Transport contract
Address review feedback on PR #2044.

safehttp.Transport():
* Clone http.DefaultTransport instead of building a bare &http.Transport{} so
  Proxy, ForceAttemptHTTP2, MaxIdleConns, IdleConnTimeout, TLSHandshakeTimeout
  and ExpectContinueTimeout are inherited (the bare struct loses them all).
  Verified by new TestTransport_PreservesDefaultTransportSettings.
* TestTransport_AllowsPublic: bound the dial of TEST-NET-3 with a 100ms
  context so the test does not depend on real-world routing of 203.0.113.0/24,
  and drop the dead dialer var.

proxy/image.go:
* Document Image.Transport contract: nil installs safehttp.Transport (SSRF-safe);
  caller-supplied transport is the caller's responsibility.
* Replace the misleading "SSRF mitigated by safehttp.Transport" nolint comments
  with one that points at the documented contract above.
2026-04-18 02:32:31 -05:00
Dmitry VerkhoturovandUmputun e98657a88a chore(lint): cap multipart upload size and suppress remaining gosec G70x
Address all golangci-lint v2.10.1 (CI's version) findings:

* Add http.MaxBytesReader hard cap to ParseMultipartForm sites in
  rest_private.savePictureCtrl (32MB) and api/migrator (256MB) — fixes
  G120 by bounding total request body before form parsing.

* Suppress G70x in CLI subcommands cmd/{backup,cleanup,import,remap}.go:
  all four issue HTTP requests against operator-supplied RemarkURL/CLI
  flags, never user input. Each suppression carries a one-line reason.

* Suppress G122 in image fs_store cleanup walk: staging directory tree
  is server-only, no untrusted symlinks land there.
2026-04-18 02:32:31 -05:00
Dmitry VerkhoturovandUmputun a96bddcb8d chore(lint): suppress gosec G70x false positives in admin/CLI paths
CI's golangci-lint v2.10.1 (newer rule set than my local 2.11.4) flags
four G70x cases the previous run missed. All are false positives:
backup.go and cleanup.go drive HTTP requests against the operator's own
RemarkURL from CLI flags (not user input); migrator.go removes a temp
file whose name was returned by os.CreateTemp (server-controlled). Add
targeted //nolint:gosec comments naming the reason at each site.
2026-04-18 02:32:31 -05:00
Dmitry VerkhoturovandUmputun 5ff5059db3 chore(lint): re-enable gosec G703/G704/G705 with targeted suppressions
Commit aca0cff3 silenced the path-traversal, SSRF and XSS taint rules
project-wide as "false positives" while fixing image-proxy SSRF. With
the path-traversal and TitleExtractor SSRF gaps now closed, restore the
rules so future regressions get flagged. The four genuine false positives
that remain (image proxy http.NewRequest, QR png Write, two RSS XML
Writes) get individual //nolint:gosec comments naming the reason.
2026-04-18 02:32:31 -05:00
Dmitry VerkhoturovandUmputun ff85bbc5ea fix(ssrf): apply ssrf-safe transport to TitleExtractor
The image proxy got an ssrfSafeTransport in commit aca0cff3 that resolves
DNS first, blocks any IP in private/reserved CIDRs, then dials by IP to
defeat DNS rebinding. The TitleExtractor used to construct comments'
PostTitle from Locator.URL — a user-supplied field — was missed by that
fix and kept using http.DefaultTransport. The hostname allowlist there
checks the parsed URL host but never the IP it resolves to, so a domain
suffix-matching an allowed host (or 127.0.0.1 itself when AllowedHosts
is empty) reaches the metadata service or any other internal endpoint.

The same gosec rule (G704) was excluded globally in .golangci.yml as part
of aca0cff3, so this gap was not caught by the linter either.

Extract the transport into a new safehttp package so it lives in one
place and can be reused, then pass safehttp.Transport() into the
TitleExtractor's http.Client at construction (cmd/server.go). The image
proxy switches to safehttp.Transport() too — same behaviour, no longer
duplicated.

Reproduction in title_test.go uses the production-style client to hit
an httptest.Server (always 127.0.0.1) and asserts the dialer refuses
even though "127.0.0.1" is in the allowed-domains list. A control case
shows the same setup without safehttp.Transport returns the page —
making the original vulnerability explicit.
2026-04-18 02:32:31 -05:00
Dmitry VerkhoturovandUmputun 5d88c1b2fa fix(api): drop QR-write nolint dup + trim dead .. check
Address PR #2045 review (umputun):

* The //nolint:gosec on telegramQrCtrl's w.Write(png) was byte-identical
  to the same line in #2044 (gosec-rule restoration). Drop it here so
  the two PRs do not conflict; #2044 owns it.
* `seg == ".."` in safePictureSegment was already covered by the
  strings.Contains(seg, "..") check two lines down — trim and add an
  inline comment so the cover-by-superset is explicit.
2026-04-18 02:15:53 -05:00
Dmitry VerkhoturovandUmputun 114a1be2e9 fix(api): reject control characters in /picture URL segments
Address PR #2045 review feedback (Copilot #2045-1). The previous
safePictureSegment allowed CR/LF/TAB through, so a request such as
GET /api/v1/picture/dev%0Auser/abc.png would inject literal newlines
into the access log line ("GET - /api/v1/picture/dev\nuser/abc.png ...")
— a log-forgery primitive against any operator parsing those logs.

Reject any unicode.IsControl rune in either segment (NUL was already
caught via strings.ContainsAny). New TestRest_LoadPictureRejectsControlCharsInSegment
covers LF, CR, TAB, NUL across both segments.
2026-04-18 02:15:53 -05:00
Dmitry VerkhoturovandUmputun 59c92f8c4d fix(api): reject path traversal and sanitise error in /picture/{user}/{id}
The unauthenticated GET /api/v1/picture/{user}/{id} handler concatenated the
two URL params verbatim into a filesystem path via path.Join, so a request
like /api/v1/picture/../remark.db resolved to <base>/../remark.db, escaping
the image directory. With Partitions=0 (a documented option) this is a
direct arbitrary-file read; with the default Partitions=100 the constructed
path lands in a CRC-derived subdirectory but the server still leaks the
internal filesystem path back to the unauthenticated caller via the JSON
error body — confirmed against demo.remark42.com (master-80c12a3) which
returned `stat /var/folders/.../staging/.../remark.db` for `..` requests.

Validate both URL segments via safePictureSegment (no traversal markers,
no path separators, no NULs) at the handler entry, and replace the raw
storage error with a generic "image not found" response. The original
error is logged for operators.

Reproduction test asserts that ../remark.db, foo/..%2Fremark.db and
%2E%2E/remark.db all return 400 with no internal path leaked.
2026-04-18 02:15:53 -05:00
Dmitry VerkhoturovandUmputun ddcb2c7b5f test(store): use time.UTC in test fixtures to be timezone-agnostic
The store tests stored timestamps with time.Local in their fixtures and
asserted equality against returned values that the engine round-trips
through UTC. assert.Equal compares zone identity, so on UTC machines
(CI, most cloud envs) Local==UTC and the tests passed; on a developer
machine in any other timezone (here BST, UTC+1) TestService_Put,
TestService_List, TestBoltDB_InfoPost, TestBoltDB_InfoList and several
others would fail with same wall-clock numbers but mismatched zones.

Replace time.Local with time.UTC across store/comment_test.go,
store/formatter_test.go, store/service/service_test.go,
store/engine/bolt_test.go, store/engine/engine_test.go. Production code
is untouched.
2026-04-17 19:38:11 -05:00
Dmitry VerkhoturovandUmputun f8ba38779b fix(api): require explicit ?site= in matchSiteID middleware
matchSiteID guarded most authenticated and admin routes with
`if siteID != "" && user.SiteID != siteID`. Dropping the ?site= query
parameter made the check no-op and any authenticated user passed the
middleware. Downstream handlers fell back to reading site from the JSON
body or just used the empty string, so on email/telegram subscribe
endpoints (which read site from body) a user authenticated to siteA
could perform actions targeting siteB without the cross-site guard
ever firing.

Require ?site= to be present and to match user.SiteID. Body-only site
flows are still supported provided the URL also carries the matching
?site= — both must agree, which removes the bypass and keeps the
declared site visible to the middleware.

Reproduction TestRest_matchSiteID enumerates four cases (matching,
mismatched, missing, empty). Existing test calls that relied on the
implicit pass had to add ?site=remark42 to the URL: the addComment
helper now derives the param from c.Locator.SiteID, picture upload
URL gets the param explicitly, and the email/telegram subscribe table
adds it to every endpoint. The negative cases that previously asserted
StatusBadRequest from the handler now correctly assert StatusForbidden
from the middleware.
2026-04-17 19:35:50 -05:00
AlexMa233andGitHub 94d1f6e224 feat: custom oauth2 provider (#2006)
* feat: add configurable custom OAuth2 provider and icons

* fix: reserve built-in custom provider names

* fix: add nolint directive for sha1 import

* fix: harden custom oauth provider validation
2026-04-16 23:10:05 -05:00
Adán Román RuizandUmputun ba3df171d1 #2025 Fix typo in Spanish localization for sort-by 2026-04-14 16:11:30 -05:00
Amir MohamadandGitHub 7ec5af8068 Fix Firefox dark mode white background on comment iframe (#2023)
* fix(embed): set color-scheme on iframe to fix Firefox dark mode

Firefox renders a white background in dark mode when color-scheme is 'none' on the iframe. Set color-scheme to match the active theme on both the outer iframe element and the inner document root, so Firefox uses the correct rendering mode from the start and on theme changes.

* fix(embed): default iframe color-scheme to light when no theme set

Changes the fallback from 'light dark' to 'light' to match the inner document's default behavior, which always defaults to light when no theme is specified.
2026-04-14 15:35:41 -05:00
Dmitry VerkhoturovandUmputun fc6f15534e fix(frontend): preserve orig verbatim in edit textarea (#2040)
The edit textarea was running `data.orig` through the browser's HTML
parser via a detached `<span>.innerHTML` to "decode entities", which
turned user-typed `&lt;`/`&gt;` into real `<`/`>`. On save, blackfriday
then saw a real `<script>` tag, bluemonday stripped it, and the comment
body collapsed to an empty string.

The decode block predates commit 243c835 (2022) which stopped the
backend from sanitising `orig` with bluemonday. Before 243c835, orig
came back HTML-escaped from the API and the frontend compensated.
After 243c835 the backend stores and returns orig byte-for-byte, but
the frontend decode was never removed — so it has been silently
corrupting user input containing entities for ~3.5 years.

The backend contract is clear: `orig` is the raw user input, never
rendered as HTML. The frontend should echo it back into the textarea
unchanged. This change removes the decode and adds 45 table-driven
regression tests covering entity round-trips, unicode edge cases,
and markdown constructs.
2026-04-12 11:55:24 -05:00
Dmitry VerkhoturovandUmputun 80c12a3f10 chore(deps): update Go modules
Bump Go dependencies in both backend/ and backend/_example/memory_store.

Notable updates:
- github.com/go-pkgz/lgr v0.12.1 -> v0.12.3
- github.com/klauspost/compress v1.18.2 -> v1.18.5
- github.com/PuerkitoBio/goquery v1.11.0 -> v1.12.0
- github.com/montanaflynn/stats v0.7.1 -> v0.9.0
- github.com/redis/go-redis/v9 v9.17.2 -> v9.18.0
- github.com/slack-go/slack v0.17.3 -> v0.21.1
- go.mongodb.org/mongo-driver v1.17.6 -> v1.17.9
- golang.org/x/crypto v0.48.0 -> v0.50.0
- golang.org/x/net v0.49.0 -> v0.53.0
- golang.org/x/image v0.36.0 -> v0.39.0
- golang.org/x/sys v0.41.0 -> v0.43.0
- golang.org/x/{oauth2,sync,text} minor bumps

Key markdown/sanitisation libs (bluemonday v1.0.27,
alecthomas/chroma/v2 v2.23.1, russross/blackfriday/v2 v2.1.0,
Depado/bfchroma/v2 v2.0.0) are already at the latest available
versions and were not bumped.

Verified the Chroma span-class allowlist regex in
backend/app/store/comment.go:128-131 is still fully in sync with
chroma/v2 types.go StandardTypes map (86 classes, byte-equal after
sorting). The inline comment references commit c263f6f which is
stale (Chroma is at v2 now), but the class list content is current.

Ran `go mod tidy` + `go mod vendor` + full race test suite on both
modules. All green. Added a reminder in CLAUDE.md that updating
backend/ Go modules also requires `go mod tidy` in
backend/_example/memory_store since the example module uses a
local replace directive and inherits indirect deps from the main
module.
2026-04-12 11:52:57 -05:00
UmputunandGitHub bea67f0136 Merge pull request #2032 from umputun/dependabot/go_modules/backend/_example/memory_store/golang.org/x/image-0.38.0
Bump golang.org/x/image from 0.36.0 to 0.38.0 in /backend/_example/memory_store
2026-04-04 23:19:12 -05:00
dependabot[bot]andGitHub 8b9e5c6c8c Bump golang.org/x/image in /backend/_example/memory_store
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.36.0 to 0.38.0.
- [Commits](https://github.com/golang/image/compare/v0.36.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.38.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 16:29:36 +00:00
Dmitry VerkhoturovandGitHub 06436ff9b0 Migrate batch 1 components from BEM to CSS Modules (#2014)
* feat: migrate batch 1 components from BEM to CSS Modules

Migrate 8 components from BEM to CSS Modules:
- button (7 BEM files -> 1 module)
- dropdown (7 BEM files -> 1 module)
- thread (3 BEM files -> 1 module)
- auth-panel (2 BEM files -> 1 module)
- dropdown-item, list-comments, subscribe-by-rss, settings (from batch 0 PR #2013)

Consolidates 19 BEM CSS files into 8 CSS Module files. Uses clsx for
conditional class composition, replacing bem-react-helper's b() calls.
Class naming follows the established convention: BEM block = .root,
elements = camelCase, modifiers = camelCase.

Visual regression verification on built artefacts:
- remark.css: 43,779 -> 43,299 bytes (480 bytes smaller)
- last-comments.css: 18,792 -> 18,776 bytes (16 bytes smaller)
- remark.js: 256,709 -> 304,837 bytes (48KB larger, expected: CSS Module
  classname mappings now live in JS instead of plain strings)
- Dark theme: pixel-identical (zero difference)
- Light theme: pixel-identical (0.21% diff is the native demo page
  "Toggle theme" button, not any remark42 widget element)

Also updates CLAUDE.md CSS guideline to reflect the migration status.

* Migrate remaining BEM components to CSS Modules (final batch)

Migrate the last 4 BEM components to CSS Modules, completing the
migration and removing bem-react-helper from the project entirely.

Components migrated:
- subscribe-by-email (1 BEM CSS file -> 1 module)
- comment-form + markdown-toolbar (20 BEM CSS files -> 2 modules)
- comment (19 BEM CSS files -> expanded existing module)
- root (10 BEM CSS files -> expanded existing module)

Consolidates ~50 BEM CSS files into 4 new + 2 expanded CSS Module files.
Removes bem-react-helper dependency — all components now use clsx for
conditional class composition.

Dead CSS cleanup during migration:
- Orphaned comment-actions selectors in comment theme CSS (already migrated)
- Dead BEM modifiers: comment_disabled, comment_pinned, comment_guest
- Dead element: comment__user-id (CSS existed but never used in TSX)
- Dead button type classes: comment-form__button_type_preview/_send
- Dead mix values: auth-email-login-form__back-button, comment-form__email-dropdown

Key implementation details:
- comment_highlighting stays global via :global() (imperatively added by classList)
- Bare .dark/.light theme class preserved on root wrapper (8+ modules depend on it)
- raw-content.css kept as global utility CSS (syntax highlighting)

Visual regression verification on built artefacts:
- remark.css: 43,779 -> 36,106 bytes (-17.5%)
- last-comments.css: 18,792 -> 13,955 bytes (-25.7%)
- remark.js: 256,709 -> 253,637 bytes (-1.2%)
- last-comments.js: 121,726 -> 120,795 bytes (-0.8%)
- Total: 441,006 -> 424,493 bytes (-3.7%)
- Screenshot comparison: pixel-identical across light/dark themes
2026-03-25 16:53:32 -05:00
Dmitry VerkhoturovandGitHub b888a53759 Migrate dropdown-item, list-comments, subscribe-by-rss, and settings from BEM to CSS Modules (#2013)
Consolidate legacy BEM CSS files into CSS Modules for 4 components:
- dropdown/__item: 1 CSS file → dropdown-item.module.css
- list-comments: 1 CSS file → list-comments.module.css (removed unused
  comments-list class that had no CSS rules)
- comment-form/__subscribe-by-rss: 1 CSS file → subscribe-by-rss.module.css,
  removed dead titleClass prop and dead __rss-link directory
- settings: 10 CSS files → settings.module.css, removed dead
  .settings__blocked-users-username CSS rule

Built artefact comparison (master vs branch):
- 83 of 89 files in /srv/web/ are byte-identical (all locale bundles,
  SVGs, HTML pages unchanged)
- 6 files differ: remark.css/js/mjs and last-comments.css/js/mjs
- CSS changes are class name hash shifts (e.g. G_A → H_A) caused by
  webpack's module ordering, plus 3 new var() fallback values added
  by the CSS modules build; all property:value pairs are preserved
- JS changes are minified variable name shifts (O ↔ A, I ↔ L) from
  changed import order; no logic changes
- Visual comparison (pixel-by-pixel screenshots of both light and dark
  themes on the demo page) shows 0 different pixels
- Bundle sizes: remark.css -626 bytes, remark.js -512 bytes,
  last-comments.css -16 bytes (dead CSS removed)
2026-03-25 16:42:44 -05:00
Dmitry VerkhoturovandGitHub c26f45e55e Clean up deprecated CSS and fix silent CSS bugs in frontend (#2012)
* frontend: remove deprecated iframe attrs and non-standard CSS

Three separate cleanups:

1. remove deprecated HTML attributes from iframe creation (create-iframe.ts)
   - frameborder="0": deprecated since HTML5; border is already set to none via CSS
   - allowtransparency="true": non-standard Microsoft attribute never in any spec;
     transparency is handled by body { background: transparent } in CSS instead
   - scrolling="no": deprecated since HTML5; overflow is already hidden via CSS
   - horizontalscrolling/verticalscrolling: non-standard IE-era attributes with
     no effect in modern browsers; remove without replacement

2. replace allowtransparency with explicit CSS (global.css)
   - add background: transparent to body; this is the spec-correct way to make
     an iframe document transparent, as documented by MDN

3. drop -moz-touch-enabled media query prefix (5 comment CSS files)
   - -moz-touch-enabled was a Firefox-only non-standard media feature removed
     in Firefox 58 (2018); pointer: coarse is the standard equivalent and was
     already present as the second condition in every query, so removing the
     dead -moz prefix reduces the media query to just (pointer: coarse)

note: colorScheme: 'none' in create-iframe.ts is intentionally left unchanged;
it is tracked by #1430 and requires a broader color-scheme implementation

* frontend: fix CSS bugs and replace deprecated properties

Bugs fixed:

- comment-votes.module.css: add missing comma between transition values;
  without it the shorthand was invalid and colour transitions on vote
  buttons were silently ignored

- icon-button.module.css: fix "transfrom" typo (should be "transform");
  the misspelling made the transition declaration a no-op, so the hover
  scale animation jumped instantly instead of easing

- auth.module.css: remove doubly-nested rgb(rgb(var(…))) call; the outer
  rgb() rejected the inner rgb() result, so the .title element's colour
  fell back to inherited instead of the intended --secondary-text-color

Deprecated properties replaced:

- comment-form__markdown-toolbar.css: replace deprecated clip: rect()
  with clip-path: inset(50%); clip was deprecated in CSS Masking Level 1

- raw-content.css: replace word-wrap with overflow-wrap; word-wrap was
  renamed in CSS Text Level 3, all current browsers support overflow-wrap

- global.css: remove redundant literal-colour fallback lines before
  var() declarations in .preloader and .preloader_view_iframe; the var()
  calls already have inline fallback values (e.g. var(--color6, #fff)),
  making the preceding duplicate property and its stylelint-disable
  comment unnecessary since IE11 EOL

* move border:none from inline style to widget__comments-frame class
2026-03-25 16:42:40 -05:00
Dmitry VerkhoturovandGitHub ba7c3aed94 refactor: modernise Go code with go fix and manual improvements (#2027)
Apply go fix ./... analysers (Go 1.26) across backend and examples:
- interface{} → any (type alias, no behaviour change)
- for i := 0; i < N; i++ → for range N / for i := range N
- slices.Contains / slices.ContainsFunc replacing manual loops
- strings.SplitSeq replacing strings.Split in range (avoids allocation)
- strings.CutPrefix replacing HasPrefix+TrimPrefix
- min() replacing manual if/else
- fmt.Appendf replacing []byte(fmt.Sprintf(...))
- strings.Builder replacing string += concatenation
- wg.Go(func(){}) replacing wg.Add(1)/go/wg.Done() pattern
- removed redundant ii := i loop variable copies (unnecessary since Go 1.22)

omitempty on struct-typed JSON fields: go fix removed omitempty from
struct-typed fields (time.Time, PostInfo, UserDetailEntry) because
encoding/json's omitempty never applied to struct types — it was always
a no-op. Kept as bare tags (no omitzero replacement) to preserve the
existing serialisation behaviour.
2026-03-25 16:42:37 -05:00
UmputunandGitHub 8aafc8fcd7 Merge pull request #2020 from paskal/ci/add-pnpm-cache
ci: add node dependency caching
2026-03-16 01:38:00 -05:00
Dmitry Verkhoturov ab9e6675cf fix type check failure in @remark42/api package
Add skipLibCheck to skip type checking of .d.ts files in node_modules,
matching the setting already used by the main remark42 app. Fixes
@types/eslint-scope vs @types/eslint type incompatibility.
2026-03-07 21:37:46 +00:00
Dmitry Verkhoturov ed67390dea ci: add pnpm dependency caching via setup-node
Replace manual actions/cache steps with built-in setup-node cache support.
Add cache: pnpm and cache-dependency-path to all setup-node steps in both
ci-frontend.yml and ci-frontend-api.yml. Move pnpm install before setup-node
as required for pnpm caching to work.
2026-03-07 21:22:25 +00:00
Umputun aca0cff399 fix: IPv6 address truncation and image proxy SSRF vulnerabilities
Replace strings.Split(RemoteAddr, ":") with net.SplitHostPort for correct
IPv6 address extraction in vote deduplication and comment IP tracking.

Harden image proxy: add SSRF-safe transport blocking private/reserved IPs
at connection time with DNS rebinding protection, sanitize error messages
to prevent information leakage, add response size limit via io.LimitReader.

Fix shadowed error variables in BlockedUsers, SetTitle, and Delete methods.
Exclude gosec taint analysis false positives at linter config level.
2026-02-28 04:13:07 -06:00
Dmitry VerkhoturovandUmputun f359256489 docs: document placeholder support in the remark42 div (#1990)
Clarify that any content placed inside the `<div id="remark42">` is
automatically removed once the iframe signals it has initialised.
Update all code examples across getting-started, frontend config, and
Astro/Gatsby integration guides to use "Comments loading..." as the
placeholder so the feature is visible by default.
2026-02-22 17:54:43 -06:00
Dmitry VerkhoturovandUmputun 336f17e7b7 Document EDIT_TIME=0 behavior in parameters
Setting edit-time to 0 disables both comment editing and staged image cleanup.
2026-02-22 17:54:19 -06:00
Dmitry VerkhoturovandUmputun 0105bc2314 Drop GitHub token permissions on deploy jobs
Deploy jobs only curl an external updater URL and need no GitHub API
access. Without an explicit permissions block they inherit the workflow
default, which may include contents:write, packages:write, etc.
Setting permissions to {} limits the blast radius if a job is
compromised.
2026-02-21 20:16:00 -06:00
Dmitry VerkhoturovandUmputun 78d6de6bce Add X-Content-Type-Options and Referrer-Policy security headers
Add two missing security headers to the existing securityHeadersMiddleware:

- X-Content-Type-Options: nosniff — prevents browsers from MIME-sniffing
  responses away from the declared Content-Type, stopping e.g. a
  user-uploaded image from being reinterpreted as executable HTML/JS

- Referrer-Policy: strict-origin-when-cross-origin — limits URL information
  leaked in the Referer header on cross-origin requests to just the origin
  (no path), and sends nothing at all on HTTPS-to-HTTP downgrades
2026-02-21 20:14:44 -06:00
dependabot[bot]GitHubpaskaldependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
638fa63e81 Bump the go-modules-updates group in /backend with 7 updates (#1995)
* Bump the go-modules-updates group in /backend with 7 updates

Bumps the go-modules-updates group in /backend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) | `2.21.1` | `2.23.1` |
| [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.2.3` | `5.2.4` |
| [github.com/go-pkgz/rest](https://github.com/go-pkgz/rest) | `1.20.6` | `1.21.0` |
| [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | `5.3.0` | `5.3.1` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.46.0` | `0.47.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.34.0` | `0.35.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.48.0` | `0.49.0` |


Updates `github.com/alecthomas/chroma/v2` from 2.21.1 to 2.23.1
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.21.1...v2.23.1)

Updates `github.com/go-chi/chi/v5` from 5.2.3 to 5.2.4
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.3...v5.2.4)

Updates `github.com/go-pkgz/rest` from 1.20.6 to 1.21.0
- [Release notes](https://github.com/go-pkgz/rest/releases)
- [Commits](https://github.com/go-pkgz/rest/compare/v1.20.6...v1.21.0)

Updates `github.com/golang-jwt/jwt/v5` from 5.3.0 to 5.3.1
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.3.0...v5.3.1)

Updates `golang.org/x/crypto` from 0.46.0 to 0.47.0
- [Commits](https://github.com/golang/crypto/compare/v0.46.0...v0.47.0)

Updates `golang.org/x/image` from 0.34.0 to 0.35.0
- [Commits](https://github.com/golang/image/compare/v0.34.0...v0.35.0)

Updates `golang.org/x/net` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.23.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/rest
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/crypto
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/image
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/net
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

* Run go mod tidy in examples directory

Co-authored-by: paskal <712534+paskal@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: paskal <712534+paskal@users.noreply.github.com>
2026-02-14 19:48:24 -06:00
UmputunandGitHub e3b0d63648 Merge pull request #1999 from umputun/configurable-microsoft-tenant
feat: make Microsoft Entra ID tenant configurable
2026-02-10 22:53:31 -06:00
UmputunandGitHub b38d91cb0f Merge pull request #2000 from umputun/fix/quick-fixes-1946-1991-1996
Fix email encoding, image cleanup CPU spin, and demo template paths
2026-02-10 22:52:40 -06:00
UmputunandGitHub d6d53ff2e0 Merge pull request #2001 from umputun/fix/admin-edit-frontend-1986
Fix frontend not respecting ADMIN_EDIT config
2026-02-10 22:51:32 -06:00
UmputunandGitHub 195becc6ee Merge pull request #2002 from umputun/fix/placeholder-clearing-1990
Clear user placeholder content when comments iframe loads
2026-02-10 22:50:51 -06:00
UmputunandGitHub 5bc5167a31 Merge pull request #2003 from umputun/docs/email-template-variables
Document email template variables and plain-text email setup
2026-02-10 22:50:20 -06:00
UmputunandGitHub 1320b1f055 Merge pull request #1984 from umputun/dependabot/github_actions/github-actions-updates-35b2a8182b
Bump the github-actions-updates group with 3 updates
2026-02-10 22:40:25 -06:00
UmputunandGitHub 283e2c19c7 Merge pull request #1994 from umputun/dependabot/npm_and_yarn/frontend/lodash-es-4.17.23
Bump lodash-es from 4.17.21 to 4.17.23 in /frontend
2026-02-10 22:40:18 -06:00
UmputunandGitHub 55d9e22373 Merge pull request #1997 from umputun/dependabot/npm_and_yarn/frontend/webpack-5.104.1
Bump webpack from 5.73.0 to 5.104.1 in /frontend
2026-02-10 22:40:09 -06:00
Dmitry Verkhoturov 31e20fc26d feat: make Microsoft Entra ID tenant configurable
Add AUTH_MICROSOFT_TENANT env var to allow configuring the Azure AD
tenant for single-tenant Entra ID applications, which cannot use the
default /common endpoint.

Depends on go-pkgz/auth#266

Closes #1998
2026-02-11 00:45:38 +00:00
Dmitry Verkhoturov c2cc2305c1 Document email template variables and plain-text email setup 2026-02-11 00:08:05 +00:00
Dmitry Verkhoturov 4d0bd29b45 Clear placeholder content when comments iframe loads
Remove non-iframe child nodes from the root element once the
iframe signals it has initialised, allowing users to add
loading placeholders that get cleaned up automatically. Fixes #1990
2026-02-10 23:52:22 +00:00
Dmitry Verkhoturov a1215d87d9 Fix frontend not respecting ADMIN_EDIT for comment editing
Add admin_edit field to frontend Config types and use it in
comment component to give admins unlimited edit time and allow
editing comments with replies. Hide countdown timer when
editDeadline is Infinity. Fixes #1986
2026-02-10 23:52:17 +00:00
Dmitry Verkhoturov 3c2679a1d5 Fix hardcoded /web paths in demo template
Use REMARK_URL template variable for widget links so demo page
works with non-root path prefixes. Fixes #1996
2026-02-10 23:52:12 +00:00
Dmitry Verkhoturov 79177e52f9 Fix 100% CPU when EDIT_TIME=0 in image cleanup
When EditDuration is zero or negative, cleanupTTL becomes zero,
causing time.After(0) to fire immediately in a tight loop.
Block on ctx.Done() instead when edit duration is disabled. Fixes #1991
2026-02-10 23:52:12 +00:00
Dmitry Verkhoturov baa615a0d1 Fix NOTIFY_EMAIL_FROM plus sign encoding in mailto URLs
URL-encode e.From in mailto query parameters so that + characters
are preserved instead of being decoded as spaces. Fixes #1946
2026-02-10 23:52:12 +00:00
dependabot[bot]andGitHub e665dcf9e2 Bump webpack from 5.73.0 to 5.104.1 in /frontend
Bumps [webpack](https://github.com/webpack/webpack) from 5.73.0 to 5.104.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.73.0...v5.104.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.104.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-06 23:31:33 +00:00
Dmitry VerkhoturovandUmputun b7a13a6636 Fix site rebuild on release
The paths filter was applied to tag events, preventing site rebuilds
when releases don't include site changes. Switch to release event
trigger which always fires on new releases, ensuring the site fetches
the latest version from GitHub API.

Closes #1992
2026-02-05 11:06:58 -06:00
dependabot[bot]andGitHub 218570cfad Bump lodash-es from 4.17.21 to 4.17.23 in /frontend
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-21 23:06:53 +00:00
dependabot[bot]andGitHub 4c9a791d6d Bump the github-actions-updates group with 3 updates
Bumps the github-actions-updates group with 3 updates: [actions/cache](https://github.com/actions/cache), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/cache` from 4 to 5
- [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/v4...v5)

Updates `actions/upload-artifact` from 5 to 6
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

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

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/download-artifact
  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-01-01 11:09:55 +00:00
Umputun cdad560df3 Update backend base image to buildgo-v1.17.0 in Dockerfile for artifacts build 2025-12-24 02:55:38 -06:00
Umputun 307e69e5c1 Bump dependencies
- chroma/v2: v2.20.0 → v2.21.1
- go-pkgz/auth/v2: v2.1.0 → v2.1.1
- go-pkgz/rest: v1.20.4 → v1.20.6
- golang.org/x/* packages to latest

Also exclude "meaningless package names" revive warning in linter config.
2025-12-24 01:48:14 -06:00
Dmitry VerkhoturovandUmputun d5b07d7670 Fix WriteHeader + RenderJSON causing wrong Content-Type header
Replace WriteHeader() + RenderJSON() pattern with EncodeJSON() which
properly sets Content-Type header before writing status code. The
previous pattern caused Content-Type to default to text/plain instead
of application/json, breaking frontend JSON parsing.

Fixes #1979
2025-12-16 13:01:19 -06:00
Dmitry VerkhoturovandGitHub 41b75eba08 Merge pull request #1977 from umputun/docker-native-arm64-runners
Improve GitHub Actions workflows security and performance
2025-12-08 14:46:13 -06:00
UmputunandGitHub 4aa8362de1 Merge pull request #1976 from umputun/ci-native-arm64-runners
Migrate Docker builds to native GitHub ARM64 runners
2025-12-07 16:01:57 -06:00
Umputun dc168f69bf Fix dual-registry Docker builds and drop armv7
- Remove armv7 platform (eliminates QEMU emulation bottleneck)
- Build separately to ghcr.io and DockerHub with distinct digests
- Create registry-specific manifests from corresponding digests
- Update expected digest count from 3 to 2 per registry
2025-12-07 15:58:35 -06:00
Dmitry Verkhoturov 2a4a1591fa Migrate Docker builds to native GitHub ARM64 runners
Replace QEMU emulation with GitHub's native ARM64 runners for faster builds:
- Use ubuntu-24.04-arm for ARM builds instead of QEMU emulation
- Split build job into matrix for parallel platform builds
- Add digest-based workflow for multi-arch manifest creation
- Keep build-test on ARM64 runner for faster PR verification
2025-12-07 21:39:59 +00:00
Dmitry VerkhoturovandUmputun bc612ddf81 Remove redundant frame ancestors log from middleware
The log message was printed on every request when allowed ancestors
were configured, creating unnecessary noise in the logs.
2025-12-04 11:15:06 -06:00
Dmitry VerkhoturovandUmputun 9132a6158b Add display name format to EMAIL_FROM examples in docs
Show users they can use "Display Name"<email@example.com> format
for AUTH_EMAIL_FROM and NOTIFY_EMAIL_FROM settings.
2025-12-04 11:14:32 -06:00
Dmitry VerkhoturovandUmputun 658bf307b1 Add mailto: to CSP frame-src directive
Allow mailto links on the deleteme page to work without being blocked
by Content Security Policy.
2025-12-04 11:13:50 -06:00
Fredrik AppelrosandUmputun c6efcc56a9 Fix deleteme feature
The last step of the data deletion feature–when the admin user visits the link sent by the user requesting to delete their data–was broken due to the deleteme.js script not being loaded.
2025-12-04 11:13:50 -06:00
dependabot[bot]andUmputun bdee00b662 Bump nanoid from 3.3.7 to 3.3.8 in /site
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-04 11:13:02 -06:00
kumudiaorongandUmputun 3013d03be2 fix: Modify the example to avoid misunderstanding 2025-12-04 11:12:32 -06:00
4a2bb5eda8 #1833 - Toolbar buttons are stuck to the main comment form (#1948)
* 1833 - Toolbar buttons are stuck to the main comment form

* Add readonly and JSDoc to CommentForm textareaId properties

Improve code quality based on review feedback: mark textareaId as
readonly since it should never change after construction, and add
JSDoc to static textareaCounter explaining its purpose.

---------

Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2025-12-04 11:12:05 -06:00
d01b738741 Implement function to prune string keeping HTML closing tags (#1870)
* Implement function to prune string keeping HTML closing tags

Fixes #1587

* change const name

remove unneeded comment

* move pruneHTML to separated file

* move const back to telegram.go

* Add unit tests for string array manipulation and HTML pruning

Introduce comprehensive test cases for stringArr methods (Push, Pop, Unshift, Shift, String) to ensure correct behavior and state management. Additionally, add tests for HTML pruning functions (pruneHTML, pruneStringToWord) to validate handling of length constraints and formatting scenarios.

* Improve behavior

* Fix pruneHTML to count visible text only, add parent text pruning

- Fix bug where HTML tags were counted toward the character limit
  instead of only visible text content
- Add pruning for parent comment text in Telegram notifications
- Simplify pruneStringToWord using strings.LastIndex
- Remove unused stringArr type and its tests
- Consolidate and simplify test cases

---------

Co-authored-by: Umputun <umputun@gmail.com>
Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2025-12-04 11:11:10 -06:00
Dmitry VerkhoturovandGitHub 564e8ff316 Update go dependencies (#1972) 2025-12-03 19:47:01 -06:00
dependabot[bot]andUmputun b451142790 Bump js-yaml from 3.14.1 to 3.14.2 in /site
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.1 to 3.14.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 18:18:10 -06:00
dependabot[bot]andUmputun 6d8a0c783b Bump the go-modules-updates group across 1 directory with 11 updates
Bumps the go-modules-updates group with 11 updates in the /backend directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) | `1.10.2` | `1.11.0` |
| [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) | `2.15.0` | `2.20.0` |
| [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.2.1` | `5.2.3` |
| [github.com/go-chi/cors](https://github.com/go-chi/cors) | `1.2.1` | `1.2.2` |
| [github.com/go-pkgz/jrpc](https://github.com/go-pkgz/jrpc) | `0.3.1` | `0.4.0` |
| [github.com/go-pkgz/lgr](https://github.com/go-pkgz/lgr) | `0.12.0` | `0.12.1` |
| [github.com/go-pkgz/rest](https://github.com/go-pkgz/rest) | `1.20.3` | `1.20.4` |
| [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | `5.2.2` | `5.3.0` |
| [github.com/stretchr/testify](https://github.com/stretchr/testify) | `1.10.0` | `1.11.1` |
| [go.etcd.io/bbolt](https://github.com/etcd-io/bbolt) | `1.4.0` | `1.4.3` |
| [golang.org/x/image](https://github.com/golang/image) | `0.26.0` | `0.33.0` |

Updates `github.com/PuerkitoBio/goquery` from 1.10.2 to 1.11.0
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.2...v1.11.0)

Updates `github.com/alecthomas/chroma/v2` from 2.15.0 to 2.20.0
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.15.0...v2.20.0)

Updates `github.com/go-chi/chi/v5` from 5.2.1 to 5.2.3
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.1...v5.2.3)

Updates `github.com/go-chi/cors` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/go-chi/cors/releases)
- [Commits](https://github.com/go-chi/cors/compare/v1.2.1...v1.2.2)

Updates `github.com/go-pkgz/jrpc` from 0.3.1 to 0.4.0
- [Release notes](https://github.com/go-pkgz/jrpc/releases)
- [Commits](https://github.com/go-pkgz/jrpc/compare/v0.3.1...v0.4.0)

Updates `github.com/go-pkgz/lgr` from 0.12.0 to 0.12.1
- [Release notes](https://github.com/go-pkgz/lgr/releases)
- [Commits](https://github.com/go-pkgz/lgr/compare/v0.12.0...v0.12.1)

Updates `github.com/go-pkgz/rest` from 1.20.3 to 1.20.4
- [Release notes](https://github.com/go-pkgz/rest/releases)
- [Commits](https://github.com/go-pkgz/rest/compare/v1.20.3...v1.20.4)

Updates `github.com/golang-jwt/jwt/v5` from 5.2.2 to 5.3.0
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.2...v5.3.0)

Updates `github.com/stretchr/testify` from 1.10.0 to 1.11.1
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.10.0...v1.11.1)

Updates `go.etcd.io/bbolt` from 1.4.0 to 1.4.3
- [Release notes](https://github.com/etcd-io/bbolt/releases)
- [Commits](https://github.com/etcd-io/bbolt/compare/v1.4.0...v1.4.3)

Updates `golang.org/x/image` from 0.26.0 to 0.33.0
- [Commits](https://github.com/golang/image/compare/v0.26.0...v0.33.0)

---
updated-dependencies:
- dependency-name: github.com/PuerkitoBio/goquery
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/go-chi/cors
  dependency-version: 1.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/jrpc
  dependency-version: 0.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/lgr
  dependency-version: 0.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/rest
  dependency-version: 1.20.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: go.etcd.io/bbolt
  dependency-version: 1.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/image
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 18:17:46 -06:00
notmalicikandGitHub d925584af8 Romanian language added (#1962) 2025-12-03 12:57:07 -06:00
dependabot[bot]andUmputun 22ee7a06d5 Bump playwright from 1.25.0 to 1.55.1 in /frontend
Bumps [playwright](https://github.com/microsoft/playwright) from 1.25.0 to 1.55.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.25.0...v1.55.1)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.55.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 11:44:51 -06:00
dependabot[bot]andUmputun a311ff7b38 Bump the github-actions-updates group across 1 directory with 6 updates
Bumps the github-actions-updates group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `5` |
| [actions/setup-go](https://github.com/actions/setup-go) | `5` | `6` |
| [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `6` | `8` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `6` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `4.1.0` | `4.2.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `5` |



Updates `actions/checkout` from 4 to 5
- [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/v4...v5)

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

Updates `golangci/golangci-lint-action` from 6 to 8
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v6...v8)

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

Updates `pnpm/action-setup` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4.1.0...v4.2.0)

Updates `actions/upload-artifact` from 4 to 5
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: golangci/golangci-lint-action
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/setup-node
  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: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-updates
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 11:43:50 -06:00
ClaudeandUmputun 88257931ca Update Google and X (Twitter) logos to latest brand versions
- Update Google logo to use gradient version per new brand guidelines
- Replace Twitter bird logo with X logo
- Add light/dark variants for X logo (like Apple and GitHub)
- Update oauth.consts.ts to use new X logo variants

Fixes #1957
2025-12-03 11:43:25 -06:00
dependabot[bot]andUmputun 588ec169ff Bump golang.org/x/crypto from 0.37.0 to 0.45.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 11:43:03 -06:00
dependabot[bot]andUmputun d9efa765d1 Bump golang.org/x/crypto in /backend/_example/memory_store
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 11:42:34 -06:00
Dmitry VerkhoturovandGitHub baf0db1947 Get rid of github.com/go-chi/render use (#1919)
Replace go-chi/render with go-pkgz/rest for JSON responses and custom
helpers for HTML/plain text responses.

Key changes:
- Replace render.JSON/render.Status with rest.RenderJSON and explicit
  w.WriteHeader() calls
- Replace render.DecodeJSON with json.NewDecoder().Decode()
- Add SendErrorJSON helper that sets Content-Type header before
  WriteHeader (required since rest.RenderJSON can't set headers after
  WriteHeader is called)
- Add HTMLResponse and PlainTextResponse helpers

Fix export double-execution in migrator.go:
The original code called Export twice - once to io.Discard to check for
errors, then again to actually write. This was wasteful and had a race
condition risk. Now file mode buffers to memory first for atomic
success/failure, while stream mode writes directly with proper error
handling.
2025-12-03 11:41:29 -06:00
UmputunandGitHub 34ea4c3e83 Migrate golangci-lint to v2 and update Go version (#1965)
* migrate golangci-lint to v2 and update go version

- migrated .golangci.yml to version 2 format
- updated go.mod from 1.23.0 to 1.24
- removed deprecated run.timeout configuration

* update to go 1.25 and baseimage v1.17.0

- updated go.mod to go 1.25
- updated Dockerfile to use buildgo-v1.17.0 (go 1.25.0)
- updated Dockerfile to use app-v1.17.0

* fix flaky tests with proper synchronization

- use assert.Eventually instead of fixed sleep in TestService_Many
- wait for webhook before shutdown in TestMain_WithWebhook
- fixes race conditions exposed by Go 1.25 scheduler changes

* fix data race in MockDest.closed field

- add IsClosed() method with proper locking
- add locking to String() method
- use IsClosed() in tests instead of direct field access
- fixes race condition detected by go test -race

* update example go.mod to go 1.25

* update golangci-lint to v2.6.0 for go 1.25 support

* fix linter issue and update CLAUDE.md

- merge conditional assignment in example accessor/data.go
- add reminder in CLAUDE.md to always test and lint examples before committing

* update example Dockerfile to baseimage v1.17.0 for go 1.25
2025-11-02 14:17:39 -06:00
UmputunandGitHub 8112f445f4 Merge pull request #1947 from schnerring/add-backup-docs
docs: improve backup instructions; closes #1613
2025-10-01 02:02:46 -05:00
UmputunandGitHub bd7bbe629f Merge pull request #1953 from diosfera/master
Add Macedonian translation
2025-10-01 02:01:43 -05:00
diosfera ed8b3c314d Delete package-lock.json file 2025-09-21 20:07:12 +02:00
diosfera 51ec396691 Add Macedonian translation 2025-09-17 06:29:51 +02:00
Michael Schnerring ca8ab66812 docs: improve backup instructions; closes #1613 2025-09-04 22:20:18 +02:00
UmputunandGitHub eaa64bac45 Merge pull request #1929 from umputun/fix/auth-send-jwt-header
Fix login persistence with AUTH_SEND_JWT_HEADER enabled
2025-07-06 17:55:09 -05:00
UmputunandGitHub abdca907a7 Merge branch 'master' into fix/auth-send-jwt-header 2025-07-06 15:39:12 -05:00
UmputunandGitHub a9c8cf51a0 Merge pull request #1937 from umputun/docs/telegram-group-notifications 2025-06-16 00:43:37 -05:00
Dmitry Verkhoturov d829a57061 docs: update Telegram configuration with group notification details
Add information about sending notifications to users, groups, and channels.
Document that public group usernames can be used without @ symbol as IDs.

Addresses discussion #1756
2025-06-16 05:19:50 +01:00
UmputunandGitHub 1dffb2f16e Merge pull request #1936 from talentedunicorn/patch-1
Fixed typo
2025-06-06 12:12:56 -05:00
EzeandGitHub 9e2a1da0df Fixed typo 2025-06-07 01:11:29 +08:00
UmputunandGitHub 6fbaa806f0 Merge pull request #1934 from up9cloud/master
Fix typo (rootDissapear should be rootDisappear)
2025-06-06 10:46:29 -05:00
HsüanandGitHub 58acca4dcb Fix profile.spec.tsx typo 2025-06-04 15:18:46 -07:00
HsüanandGitHub 22d21df22b Fix profile.ts typo 2025-06-04 15:17:40 -07:00
Dmitry Verkhoturov ddb490bbc1 Fix login persistence with AUTH_SEND_JWT_HEADER enabled
With AUTH_SEND_JWT_HEADER=true, frontend now properly handles JWT authentication:
- Store JWT token in client-side cookie named 'JWT'
- Extract and store XSRF token from JWT payload
- Set Secure flag automatically when on HTTPS connection
- Update documentation to clarify this behavior

This fixes an issue where login state would be lost after page reload
when using header-based JWT authentication.
2025-04-29 23:45:55 +01:00
UmputunandGitHub 242499787e Merge pull request #1930 from umputun/paskal/update-gopkgz
Update go-pkgz, system modules
2025-04-29 02:31:12 -05:00
Dmitry Verkhoturov fd0799384f Update go-pkgz, system modules
This brings stricter check for auth provider names, slog support.
2025-04-29 08:24:53 +01:00
UmputunandGitHub 61dbf6b1f2 Merge pull request #1915 from umputun/dependabot/go_modules/backend/github.com/redis/go-redis/v9-9.7.3
Bump github.com/redis/go-redis/v9 from 9.7.0 to 9.7.3 in /backend
2025-03-24 21:51:56 -05:00
UmputunandGitHub 4a3c73db31 Merge pull request #1911 from umputun/dependabot/npm_and_yarn/site/prismjs-1.30.0
Bump prismjs from 1.29.0 to 1.30.0 in /site
2025-03-24 21:51:02 -05:00
UmputunandGitHub df510360e6 Merge pull request #1909 from umputun/dependabot/github_actions/github-actions-updates-e0e9667351
Bump pnpm/action-setup from 4.0.0 to 4.1.0 in the github-actions-updates group
2025-03-24 21:50:47 -05:00
UmputunandGitHub a3309516c3 Merge pull request #1916 from umputun/dependabot/go_modules/backend/github.com/golang-jwt/jwt/v5-5.2.2
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
2025-03-24 21:50:33 -05:00
UmputunandGitHub 5e30dc86e1 Merge pull request #1917 from umputun/paskal/golangci_lint_v2
Automatic fix of errors reported by golangci-lint v2
2025-03-24 21:50:20 -05:00
dependabot[bot]andGitHub bbcba5487e Bump github.com/redis/go-redis/v9 from 9.7.0 to 9.7.3 in /backend
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.7.0 to 9.7.3.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.7.0...v9.7.3)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-25 02:49:28 +00:00
UmputunandGitHub c32e5efdc2 Merge pull request #1918 from umputun/paskal/add_systemd
Add install instructions for setting up Remark42 as a systemd service
2025-03-24 21:48:27 -05:00
UmputunandGitHub af139a7f4c Merge pull request #1910 from umputun/dependabot/go_modules/backend/go-modules-updates-e61953c257
Bump the go-modules-updates group in /backend with 8 updates
2025-03-24 21:48:05 -05:00
dependabot[bot]andDmitry V 89221ff2bc Bump the go-modules-updates group in /backend with 8 updates
Bumps the go-modules-updates group in /backend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) | `1.10.1` | `1.10.2` |
| [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) | `2.14.0` | `2.15.0` |
| [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.2.0` | `5.2.1` |
| [github.com/go-pkgz/jrpc](https://github.com/go-pkgz/jrpc) | `0.3.0` | `0.3.1` |
| [go.etcd.io/bbolt](https://github.com/etcd-io/bbolt) | `1.3.11` | `1.4.0` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.31.0` | `0.33.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.23.0` | `0.25.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.33.0` | `0.35.0` |

Updates `github.com/PuerkitoBio/goquery` from 1.10.1 to 1.10.2
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.1...v1.10.2)

Updates `github.com/alecthomas/chroma/v2` from 2.14.0 to 2.15.0
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Changelog](https://github.com/alecthomas/chroma/blob/master/.goreleaser.yml)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.14.0...v2.15.0)

Updates `github.com/go-chi/chi/v5` from 5.2.0 to 5.2.1
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.0...v5.2.1)

Updates `github.com/go-pkgz/jrpc` from 0.3.0 to 0.3.1
- [Release notes](https://github.com/go-pkgz/jrpc/releases)
- [Commits](https://github.com/go-pkgz/jrpc/compare/v0.3.0...v0.3.1)

Updates `go.etcd.io/bbolt` from 1.3.11 to 1.4.0
- [Release notes](https://github.com/etcd-io/bbolt/releases)
- [Commits](https://github.com/etcd-io/bbolt/compare/v1.3.11...v1.4.0)

Updates `golang.org/x/crypto` from 0.31.0 to 0.33.0
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.33.0)

Updates `golang.org/x/image` from 0.23.0 to 0.25.0
- [Commits](https://github.com/golang/image/compare/v0.23.0...v0.25.0)

Updates `golang.org/x/net` from 0.33.0 to 0.35.0
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.35.0)

---
updated-dependencies:
- dependency-name: github.com/PuerkitoBio/goquery
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/jrpc
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: go.etcd.io/bbolt
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-24 23:13:46 +01:00
Dmitry Verkhoturov 528242c1ca Add install instructions for setting up Remark42 as a systemd service 2025-03-24 23:09:28 +01:00
Dmitry Verkhoturov edfc5b9d76 Automatic fix of errors reported by golangci-lint v2
- Use strings.ReplaceAll
- Remove redundant internal structure names
2025-03-24 22:46:43 +01:00
dependabot[bot]andGitHub 7e944bfe0d Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-21 22:26:36 +00:00
UmputunandGitHub e6afc58b34 Merge pull request #1914 from umputun/paskal/add_system_requirements 2025-03-16 14:28:22 -05:00
Dmitry Verkhoturov f49b878eb4 Add system requirements section to installation guide 2025-03-16 19:26:42 +00:00
UmputunandGitHub 5fe843de71 Merge pull request #1912 from umputun/dependabot/go_modules/backend/golang.org/x/net-0.36.0
Bump golang.org/x/net from 0.33.0 to 0.36.0 in /backend
2025-03-12 20:30:34 -05:00
dependabot[bot]andDmitry Verkhoturov 6c9ade9062 Bump golang.org/x/net from 0.33.0 to 0.36.0 in /backend
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-12 22:58:15 +00:00
dependabot[bot]andGitHub ed3c104ba4 Bump prismjs from 1.29.0 to 1.30.0 in /site
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.29.0 to 1.30.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.29.0...v1.30.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-10 22:24:16 +00:00
dependabot[bot]andGitHub a90b4296c6 Bump pnpm/action-setup in the github-actions-updates group
Bumps the github-actions-updates group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4.0.0...v4.1.0)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-09 18:01:26 +00:00
UmputunandGitHub 0ebe893125 Merge pull request #1908 from umputun/paskal/disable_npm_updates
Disable dependabot automatic PRs for NPM modules
2025-03-09 13:00:21 -05:00
Dmitry Verkhoturov e32ca020c0 Disable dependabot automatic PRs for NPM modules
These have to be updated manually
because there are always breaking changes.
2025-03-09 17:17:20 +01:00
UmputunandGitHub 558350c546 Merge pull request #1897 from umputun/paskal/claude
Add first version of the CLAUDE.md
2025-03-01 17:19:34 -06:00
UmputunandGitHub c50a5d6245 Merge pull request #1907 from umputun/paskal/get_rid_of_dockerhub
Migrate Docker images from Docker Hub to GitHub Container Registry
2025-03-01 17:19:01 -06:00
Dmitry Verkhoturov bb650d7526 Migrate Docker images from Docker Hub to GitHub Container Registry
This commit replaces all references to `umputun/remark42` Docker images
on Docker Hub with `ghcr.io/umputun/remark42` from the GitHub
Container Registry. It updates various Docker Compose files,
documentation, and the Makefile to use the new image location.
It also updates the kubernetes example to use the latest version.

Docker Hub is going to kill free pulls for too long by now.
2025-03-01 23:15:09 +00:00
Dmitry Verkhoturov 551212db82 Add first version of the CLAUDE.md 2025-02-25 22:05:25 +00:00
dependabot[bot]andUmputun 2e00002413 Bump the go-modules-updates group in /backend with 5 updates
Bumps the go-modules-updates group in /backend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) | `1.10.0` | `1.10.1` |
| [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.1.0` | `5.2.0` |
| [github.com/go-pkgz/rest](https://github.com/go-pkgz/rest) | `1.19.0` | `1.20.2` |
| [golang.org/x/image](https://github.com/golang/image) | `0.22.0` | `0.23.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.31.0` | `0.33.0` |

Updates `github.com/PuerkitoBio/goquery` from 1.10.0 to 1.10.1
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.0...v1.10.1)

Updates `github.com/go-chi/chi/v5` from 5.1.0 to 5.2.0
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.1.0...v5.2.0)

Updates `github.com/go-pkgz/rest` from 1.19.0 to 1.20.2
- [Release notes](https://github.com/go-pkgz/rest/releases)
- [Commits](https://github.com/go-pkgz/rest/compare/v1.19.0...v1.20.2)

Updates `golang.org/x/image` from 0.22.0 to 0.23.0
- [Commits](https://github.com/golang/image/compare/v0.22.0...v0.23.0)

Updates `golang.org/x/net` from 0.31.0 to 0.33.0
- [Commits](https://github.com/golang/net/compare/v0.31.0...v0.33.0)

---
updated-dependencies:
- dependency-name: github.com/PuerkitoBio/goquery
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/rest
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-02 14:02:42 -06:00
UmputunandGitHub 81f70aa287 Merge pull request #1875 from codiflow/codiflow-patch-1 2024-12-28 20:44:53 -06:00
Christian 🦄andDmitry Verkhoturov f104e3c775 Update de.json
Updated Telegram bot description
2024-12-29 03:43:57 +01:00
UmputunandGitHub be076d03e9 Merge pull request #1878 from cubismod/webhook-params
Docs update describing available variables for webhook templating
2024-12-21 11:26:57 -06:00
Ryan e1166a48cf add a table describing additional variables available for use with webhook templating 2024-12-20 21:08:15 -06:00
Umputun 3f0789fd90 mod tidy for memory store example 2024-12-17 23:39:29 -06:00
UmputunandGitHub 518ae79556 Merge pull request #1869 from aliksend/aliksend/fix-importing-anonymous-comments-from-commento
Straightforward fix for importing anonymous comments from commento
2024-12-17 16:04:38 -06:00
UmputunandGitHub 3c55238bdd Merge pull request #1874 from umputun/dependabot/go_modules/backend/golang.org/x/crypto-0.31.0
Bump golang.org/x/crypto from 0.29.0 to 0.31.0 in /backend
2024-12-17 13:55:45 -06:00
Alik Send 556b95a655 Fix tests. Add test for anonymous comment 2024-12-16 23:16:26 -06:00
Alik Send bfef15f05f Straightforward fix for importing anonymous comments from commento
Fixes #1821
2024-12-16 23:16:26 -06:00
UmputunandGitHub c3ba55ba43 Merge pull request #1699 from umputun/paskal/comments_pagination
add pagination to GET /api/v1/find endpoint
2024-12-11 20:17:23 -06:00
dependabot[bot]andGitHub 0af8b2eab0 Bump golang.org/x/crypto from 0.29.0 to 0.31.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.29.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.29.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-11 22:23:01 +00:00
Dmitry Verkhoturov 82a0888c42 add pagination to GET /api/v1/find endpoint
`format=tree` pagination provides top-level comments with all replies
and returns the last top-level comment as `last_comment` to be used
as `offset` for the next page. If comments and replies overflow
the limit, the one stepping out of the limit will not be returned.
 If the first comment and its replies after the given offset overflow
 the limit, it will be returned with all the replies.

`format=plain` pagination works by providing all comments and returning
the last comment as `last_comment` to be used as `offset`
for the next page.
2024-12-10 14:26:55 +00:00
UmputunandGitHub d5162d3fe6 Merge pull request #1758 from umputun/paskal/jwt_v5
Update to go-pkgz/auth/v2 and golang-jwt/jwt/v5
2024-12-09 19:53:00 -06:00
Dmitry Verkhoturov e61a46efff Improve error message for checking claims.Audience 2024-12-10 00:45:04 +00:00
Dmitry Verkhoturov f473105c52 add tests for jwt5 multiple auds and improve existing tests 2024-12-09 01:54:04 +00:00
Dmitry Verkhoturov c2d386230c vendor new modules 2024-12-09 01:54:03 +00:00
Dmitry Verkhoturov e3ad01b555 migrate to go-pkgz/auth/v2 2024-12-09 01:54:03 +00:00
Dmitry Verkhoturov 8d9e55c33c update github.com/golang-jwt/jwt to v5 2024-12-09 01:54:03 +00:00
UmputunandGitHub 6402ef9cae Merge pull request #1871 from umputun/paskal/improve_tests
Improve tests
2024-12-08 19:53:15 -06:00
Dmitry Verkhoturov 4ed48dd85c Improve tests 2024-12-09 01:45:57 +00:00
UmputunandGitHub 9628312b5d Merge pull request #1865 from umputun/dependabot/go_modules/backend/go-modules-updates-81f599025a 2024-12-01 22:32:10 -06:00
UmputunandGitHub c65c2b395d Merge branch 'master' into dependabot/go_modules/backend/go-modules-updates-81f599025a 2024-12-01 22:31:58 -06:00
UmputunandGitHub 27671c50c4 Merge pull request #1866 from umputun/dependabot/npm_and_yarn/site/npm-modules-updates-for-tests-87ff55c30d 2024-12-01 22:31:40 -06:00
UmputunandGitHub 56ac1bb841 Merge branch 'master' into dependabot/npm_and_yarn/site/npm-modules-updates-for-tests-87ff55c30d 2024-12-01 22:31:28 -06:00
dependabot[bot]andDmitry Verkhoturov 0aadf0ba86 Bump the go-modules-updates group in /backend with 3 updates
Bumps the go-modules-updates group in /backend with 3 updates: [github.com/stretchr/testify](https://github.com/stretchr/testify), [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/net](https://github.com/golang/net).

Updates `github.com/stretchr/testify` from 1.9.0 to 1.10.0
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.9.0...v1.10.0)

Updates `golang.org/x/crypto` from 0.27.0 to 0.29.0
- [Commits](https://github.com/golang/crypto/compare/v0.27.0...v0.29.0)

Updates `golang.org/x/net` from 0.29.0 to 0.31.0
- [Commits](https://github.com/golang/net/compare/v0.29.0...v0.31.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 04:24:48 +00:00
UmputunandGitHub 34c667b83a Merge pull request #1862 from umputun/paskal/improve_server_test 2024-12-01 22:24:11 -06:00
dependabot[bot]andDmitry Verkhoturov f2e5758b6c Bump the npm-modules-updates-for-tests group in /site with 2 updates
Bumps the npm-modules-updates-for-tests group in /site with 2 updates: [prettier](https://github.com/prettier/prettier) and [tailwindcss](https://github.com/tailwindlabs/tailwindcss).

Updates `prettier` from 3.3.3 to 3.4.1
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.3...3.4.1)

Updates `tailwindcss` from 3.4.14 to 3.4.15
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/v3.4.15/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v3.4.14...v3.4.15)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-modules-updates-for-tests
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-modules-updates-for-tests
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 04:03:21 +00:00
Dmitry Verkhoturov cd7f616596 Fix server test configuration
Previously, tmp boltdb was always created in directory 8080.
2024-12-02 03:36:32 +00:00
UmputunandGitHub a0c412ee1e Merge pull request #1861 from umputun/paskal/example_dependabot 2024-12-01 21:33:33 -06:00
Dmitry VerkhoturovandGitHub 15f5b7dde5 Stop updating examples via dependabot
Examples should be updated alongside the backend directory and it makes no sense to have separate update PRs for it.
2024-12-02 03:04:46 +00:00
UmputunandGitHub a561588117 Merge pull request #1859 from umputun/dependabot/github_actions/github-actions-updates-d109cd9e8b
Bump codecov/codecov-action from 4 to 5 in the github-actions-updates group
2024-12-01 21:01:52 -06:00
dependabot[bot]andGitHub ac36dccd19 Bump codecov/codecov-action in the github-actions-updates group
Bumps the github-actions-updates group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `codecov/codecov-action` from 4 to 5
- [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/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-01 11:33:06 +00:00
UmputunandGitHub afdac27651 Merge pull request #1853 from umputun/paskal/docker-compose
Update docker-compose.yml with example for local reverse proxy
2024-11-19 18:59:10 -06:00
Dmitry VerkhoturovandGitHub 995c4963fb Update docker-compose.yml with example for local reverse proxy
In discussion #1852 I realised that we don't have example with reverse proxy running outside of docker, this commit fixes that.
2024-11-20 00:57:46 +00:00
UmputunandGitHub 16ff2690f0 Merge pull request #1824 from mgkbadola/discord-oauth
feat: Discord OAuth support
2024-11-18 14:57:31 -06:00
Mrigank BadolaandGitHub d0dc1131ea Merge branch 'master' into discord-oauth 2024-11-12 21:24:24 +05:30
Dmitry VerkhoturovandUmputun d5e3602e54 Fix fetch module import so that latest release version would be shown on website 2024-11-10 18:30:48 -06:00
Dmitry VerkhoturovandUmputun a4772bd9de Do not fail if frontend pre-commit hook fails 2024-11-10 18:12:43 -06:00
Dmitry VerkhoturovandUmputun d23b7003c6 Downgrade eleventy to fix the website 2024-11-10 18:05:51 -06:00
Dmitry VerkhoturovandUmputun 3646c4a871 Drop linux/arm/v7 docker image from site as it doesn't build well 2024-11-10 17:25:16 -06:00
Dmitry VerkhoturovandUmputun 29fc63f116 Clarify steps names for site deployment 2024-11-10 17:25:16 -06:00
Dmitry VerkhoturovandUmputun 8c59bab921 Add nvmrc to /site, update yarn.lock 2024-11-10 17:25:16 -06:00
Dmitry VerkhoturovandUmputun c42511d5a1 Fix cache-dependency-path for actions/setup-go caching to work, fix CI files path 2024-11-10 17:25:16 -06:00
Dmitry VerkhoturovandUmputun 9b5f6ee2c5 Remove deprecated set-output function usage in GitHub actions
The `set-output` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
2024-11-10 17:25:16 -06:00
dependabot[bot]andUmputun b5579152cb Bump the npm-modules-updates-for-tests group across 1 directory with 12 updates
Bumps the npm-modules-updates-for-tests group with 12 updates in the /frontend/packages/api directory:

| Package | From | To |
| --- | --- | --- |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `18.19.64` | `18.19.64` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `5.62.0` | `8.13.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `5.62.0` | `8.13.0` |
| [@vitest/coverage-c8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-c8) | `0.22.1` | `0.33.0` |
| [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) | `8.10.0` | `9.1.0` |
| [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) | `4.2.1` | `5.2.1` |
| [jsdom](https://github.com/jsdom/jsdom) | `20.0.3` | `25.0.1` |
| [msw](https://github.com/mswjs/msw) | `0.44.2` | `1.3.5` |
| [prettier](https://github.com/prettier/prettier) | `2.8.8` | `3.3.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `4.7.4` | `4.9.5` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `0.22.1` | `2.1.4` |

Updates `@types/node` from 18.19.64 to 22.9.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@typescript-eslint/eslint-plugin` from 5.62.0 to 8.13.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.13.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 5.62.0 to 8.13.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.13.0/packages/parser)

Updates `@vitest/coverage-c8` from 0.22.1 to 0.33.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v0.33.0/packages/coverage-c8)

Updates `eslint-config-prettier` from 8.10.0 to 9.1.0
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v8.10.0...v9.1.0)

Updates `eslint-plugin-prettier` from 4.2.1 to 5.2.1
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v4.2.1...v5.2.1)

Updates `jsdom` from 20.0.3 to 25.0.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/20.0.3...25.0.1)

Updates `msw` from 0.44.2 to 2.6.3
- [Release notes](https://github.com/mswjs/msw/releases)
- [Changelog](https://github.com/mswjs/msw/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mswjs/msw/compare/v0.44.2...v2.6.3)

Updates `prettier` from 2.8.8 to 3.3.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.8.8...3.3.3)

Updates `typescript` from 4.9.5 to 5.6.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v4.9.5...v5.6.3)

Updates `vitest` from 0.22.1 to 2.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v2.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: "@vitest/coverage-c8"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-modules-updates-for-tests
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: eslint-plugin-prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: jsdom
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: msw
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: vitest
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-10 14:00:52 -06:00
dependabot[bot]andUmputun 3c78a54be6 Bump the npm-modules-updates-for-tests group
Bumps the npm-modules-updates-for-tests group in /frontend/e2e with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [nanoid](https://github.com/ai/nanoid) and [typescript](https://github.com/microsoft/TypeScript).

Updates `@types/node` from 18.19.64 to 22.9.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `nanoid` from 4.0.2 to 5.0.8
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/4.0.2...5.0.8)

Updates `typescript` from 4.9.5 to 5.6.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v4.9.5...v5.6.3)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: nanoid
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-10 13:55:30 -06:00
Dmitry VerkhoturovandUmputun 3001b37812 Update pnpm from v7 to v8
That suppose to help with Dependabot security updates not updating lockfile.
2024-11-10 13:31:03 -06:00
Dmitry VerkhoturovandUmputun 63048cc798 Specify package manager version in frontend project configuration
This is necessary for Dependabot updates to work properly.
2024-11-10 12:42:07 -06:00
Mrigank Badola 1a27913404 chore: update go-pkgz/auth package 2024-11-10 12:40:07 +05:30
Mrigank Badola 4a39ceee8d sync: Merge branch 'master' of https://github.com/mgkbadola/remark42 into discord-oauth 2024-11-10 10:43:10 +05:30
dependabot[bot]andUmputun 1e659917d2 Bump the npm-modules-updates-for-tests group in /site with 7 updates
Bumps the npm-modules-updates-for-tests group in /site with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@11ty/eleventy](https://github.com/11ty/eleventy) | `2.0.1` | `3.0.0` |
| [@tailwindcss/typography](https://github.com/tailwindlabs/tailwindcss-typography) | `0.5.10` | `0.5.15` |
| [date-fns](https://github.com/date-fns/date-fns) | `3.3.1` | `4.1.0` |
| [markdown-it](https://github.com/markdown-it/markdown-it) | `14.0.0` | `14.1.0` |
| [markdown-it-anchor](https://github.com/valeriangalliat/markdown-it-anchor) | `8.6.7` | `9.2.0` |
| [prettier](https://github.com/prettier/prettier) | `2.8.8` | `3.3.3` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss) | `3.4.1` | `3.4.14` |


Updates `@11ty/eleventy` from 2.0.1 to 3.0.0
- [Release notes](https://github.com/11ty/eleventy/releases)
- [Changelog](https://github.com/11ty/eleventy/blob/main/docs/release-instructions.md)
- [Commits](https://github.com/11ty/eleventy/compare/v2.0.1...v3.0.0)

Updates `@tailwindcss/typography` from 0.5.10 to 0.5.15
- [Release notes](https://github.com/tailwindlabs/tailwindcss-typography/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss-typography/compare/v0.5.10...v0.5.15)

Updates `date-fns` from 3.3.1 to 4.1.0
- [Release notes](https://github.com/date-fns/date-fns/releases)
- [Changelog](https://github.com/date-fns/date-fns/blob/main/CHANGELOG.md)
- [Commits](https://github.com/date-fns/date-fns/compare/v3.3.1...v4.1.0)

Updates `markdown-it` from 14.0.0 to 14.1.0
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.0.0...14.1.0)

Updates `markdown-it-anchor` from 8.6.7 to 9.2.0
- [Release notes](https://github.com/valeriangalliat/markdown-it-anchor/releases)
- [Changelog](https://github.com/valeriangalliat/markdown-it-anchor/blob/master/CHANGELOG.md)
- [Commits](https://github.com/valeriangalliat/markdown-it-anchor/compare/v8.6.7...v9.2.0)

Updates `prettier` from 2.8.8 to 3.3.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.8.8...3.3.3)

Updates `tailwindcss` from 3.4.1 to 3.4.14
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/v3.4.14/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v3.4.1...v3.4.14)

---
updated-dependencies:
- dependency-name: "@11ty/eleventy"
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: "@tailwindcss/typography"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-modules-updates-for-tests
- dependency-name: date-fns
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: markdown-it
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-modules-updates-for-tests
- dependency-name: markdown-it-anchor
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-modules-updates-for-tests
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-modules-updates-for-tests
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 17:54:33 -06:00
daniyalandUmputun 5c586e12bd feat: add "fa" locale 2024-11-08 16:38:09 -06:00
Dmitry VerkhoturovandUmputun 73ca4a1b6d Capitalize "AS" keyword in Dockerfile FROM instructions 2024-11-08 16:27:10 -06:00
Dmitry VerkhoturovandUmputun 7604548035 Update go to 1.23, golangci-lint to 1.61, baseimage to 1.14 2024-11-08 16:19:58 -06:00
dependabot[bot]andUmputun 7529aa7e17 Bump the go-modules-updates group in /backend with 9 updates
Bumps the go-modules-updates group in /backend with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) | `1.9.2` | `1.10.0` |
| [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) | `2.13.0` | `2.14.0` |
| [github.com/go-pkgz/notify](https://github.com/go-pkgz/notify) | `1.1.1` | `1.2.0` |
| [github.com/go-pkgz/repeater](https://github.com/go-pkgz/repeater) | `1.1.3` | `1.2.0` |
| [github.com/rs/xid](https://github.com/rs/xid) | `1.5.0` | `1.6.0` |
| [go.etcd.io/bbolt](https://github.com/etcd-io/bbolt) | `1.3.10` | `1.3.11` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.25.0` | `0.27.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.18.0` | `0.22.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.27.0` | `0.29.0` |

Updates `github.com/PuerkitoBio/goquery` from 1.9.2 to 1.10.0
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.9.2...v1.10.0)

Updates `github.com/alecthomas/chroma/v2` from 2.13.0 to 2.14.0
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Changelog](https://github.com/alecthomas/chroma/blob/master/.goreleaser.yml)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.13.0...v2.14.0)

Updates `github.com/go-pkgz/notify` from 1.1.1 to 1.2.0
- [Release notes](https://github.com/go-pkgz/notify/releases)
- [Commits](https://github.com/go-pkgz/notify/compare/v1.1.1...v1.2.0)

Updates `github.com/go-pkgz/repeater` from 1.1.3 to 1.2.0
- [Release notes](https://github.com/go-pkgz/repeater/releases)
- [Commits](https://github.com/go-pkgz/repeater/compare/v1.1.3...v1.2.0)

Updates `github.com/rs/xid` from 1.5.0 to 1.6.0
- [Release notes](https://github.com/rs/xid/releases)
- [Commits](https://github.com/rs/xid/compare/v1.5.0...v1.6.0)

Updates `go.etcd.io/bbolt` from 1.3.10 to 1.3.11
- [Release notes](https://github.com/etcd-io/bbolt/releases)
- [Commits](https://github.com/etcd-io/bbolt/compare/v1.3.10...v1.3.11)

Updates `golang.org/x/crypto` from 0.25.0 to 0.27.0
- [Commits](https://github.com/golang/crypto/compare/v0.25.0...v0.27.0)

Updates `golang.org/x/image` from 0.18.0 to 0.22.0
- [Commits](https://github.com/golang/image/compare/v0.18.0...v0.22.0)

Updates `golang.org/x/net` from 0.27.0 to 0.29.0
- [Commits](https://github.com/golang/net/compare/v0.27.0...v0.29.0)

---
updated-dependencies:
- dependency-name: github.com/PuerkitoBio/goquery
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/notify
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/go-pkgz/repeater
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: github.com/rs/xid
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: go.etcd.io/bbolt
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 16:19:58 -06:00
Dmitry VerkhoturovandUmputun c1a447b873 unpin bugfix js version, use codecov github action 2024-11-08 15:56:59 -06:00
dependabot[bot]andUmputun 3c110eb0ec Bump the github-actions-updates group with 5 updates
Bumps the github-actions-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `3` | `6` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `2.4.0` | `4.0.0` |
| [actions/cache](https://github.com/actions/cache) | `3` | `4` |
| [andresz1/size-limit-action](https://github.com/andresz1/size-limit-action) | `7313b26c76b3666c1dc41e2ca05370e201a9b7de` | `94bc357df29c36c8f8d50ea497c3e225c3c95d1d` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `2` | `4` |


Updates `golangci/golangci-lint-action` from 3 to 6
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v3...v6)

Updates `pnpm/action-setup` from 2.4.0 to 4.0.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v2.4.0...v4.0.0)

Updates `actions/cache` from 3 to 4
- [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/v3...v4)

Updates `andresz1/size-limit-action` from 7313b26c76b3666c1dc41e2ca05370e201a9b7de to 94bc357df29c36c8f8d50ea497c3e225c3c95d1d
- [Release notes](https://github.com/andresz1/size-limit-action/releases)
- [Commits](https://github.com/andresz1/size-limit-action/compare/7313b26c76b3666c1dc41e2ca05370e201a9b7de...94bc357df29c36c8f8d50ea497c3e225c3c95d1d)

Updates `actions/upload-artifact` from 2 to 4
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2...v4)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: pnpm/action-setup
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: andresz1/size-limit-action
  dependency-type: direct:production
  dependency-group: github-actions-updates
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 15:56:59 -06:00
Dmitry VerkhoturovandUmputun d7494d5392 add dependabot updates for Go, TypeScript, GitHub Actions 2024-11-07 19:13:55 -06:00
W.T. ChangandUmputun c1048e95b3 fix: missing comma in example frontend configuration 2024-10-27 01:58:27 -05:00
Dmitry VerkhoturovandUmputun 5b6d8de807 Deprecate Twitter OAuth and remove from feature list 2024-10-20 17:18:14 -05:00
Dmitry VerkhoturovandUmputun dd2cff6a13 Clarify ALLOWED_HOSTS description and usage
This clarifies that the parameter sets CSP 'frame-ancestors'
to limit hosts allowed to embed comments. The commit also improves
the documentation on how to use ALLOWED_HOSTS with AUTH_SAME_SITE
for different setup scenarios.

We might want to change AUTH_SAME_SITE to `strong` in v2.0 as it works
on the subdomain of the same site as well as current Lax option.
2024-10-20 17:17:35 -05:00
Dmitry VerkhoturovandUmputun 68fe6eb55f Update docker-compose command to docker compose
Compose is part of docker command for years and during local development
 on Mac I have to change makefile locally for commands to work.
2024-10-20 17:17:10 -05:00
Dmitry VerkhoturovandUmputun 4d5f9f269b Add missing AUTH_APPLE_KID env variable cleanup 2024-10-20 16:06:50 -05:00
Dmitry VerkhoturovandUmputun 6140d82eb2 Fix CSP img-src directive to allow everything without proxy
Change the default img-src value to "*" and sets it to "'self'" when
image proxy is enabled. The previous state was inversion of this logic
which was wrong.
2024-10-20 15:55:51 -05:00
Mrigank Badola cff261c15b chore: add discord in server test file 2024-10-20 11:47:47 +05:30
Mrigank Badola 18ea40582a sync: Merge remote-tracking branch 'origin' into discord-oauth 2024-10-20 11:46:33 +05:30
Dmitry VerkhoturovandUmputun f9d4837567 Add Content-Security-Policy and Permissions-Policy headers
`Content-Security-Policy` now restricts resource loading and execution
to enhance security:
  - `default-src 'none'`: Disallow all resource loading by default.
  - `base-uri 'none'`: Prevents the use of `<base>` tag to change the
  base URL for relative URLs.
  - `form-action 'none'`: Disallows form submissions.
  - `connect-src 'self'`: Restricts the origins that can be connected to
   (via XHR, WebSockets, etc.) to the same origin.
  - `frame-src 'self'`: Restricts the origins that can be embedded using
   `<frame>` and `<iframe>` to the same origin (for `/web/` demo
    endpoint).
  - `frame-ancestors %s;`: Specifies the origins that are allowed to
  embed this content in a frame. If no specific origins are allowed, it
  defaults to `*` (any origin). This enhances security by controlling
  which sites can embed your content.
  - `img-src 'self'`: Allows images to be loaded only from the same
  origin. If `imageProxyEnabled` is true, allows images from any origin
  (`*`).
  - `script-src 'self' 'unsafe-inline'`: Allows scripts to be loaded and
   executed only from the same origin and allows inline scripts.
  - `style-src 'self' 'unsafe-inline'`: Allows styles to be loaded and
  applied only from the same origin and allows inline styles.
  - `font-src data:`: Allows fonts to be loaded from data URIs.
  - `object-src 'none'`: Disallows the use of `<object>`, `<embed>`, and
   `<applet>` tags.

`Permissions-Policy` now restricts the use of certain browser features
which we don't use to enhance user privacy and security:
  - `accelerometer=()`: Disables the use of the accelerometer sensor.
  - `autoplay=()`: Disables automatic playback of media.
  - `camera=()`: Disables the use of the camera.
  - `cross-origin-isolated=()`: Disallows the page from being treated as
   cross-origin isolated.
  - `display-capture=()`: Disables the ability to capture the display.
  - `encrypted-media=()`: Disables the use of Encrypted Media Extensions
  .
  - `fullscreen=()`: Disables the ability to use fullscreen mode.
  - `geolocation=()`: Disables the use of geolocation.
  - `gyroscope=()`: Disables the use of the gyroscope sensor.
  - `keyboard-map=()`: Disables the use of the keyboard map.
  - `magnetometer=()`: Disables the use of the magnetometer sensor.
  - `microphone=()`: Disables the use of the microphone.
  - `midi=()`: Disables the use of the MIDI API.
  - `payment=()`: Disables the Payment Request API.
  - `picture-in-picture=()`: Disables the use of Picture-in-Picture mode
  .
  - `publickey-credentials-get=()`: Disables the use of the Web
  Authentication API.
  - `screen-wake-lock=()`: Disables the ability to prevent the screen
  from dimming.
  - `sync-xhr=()`: Disables synchronous XMLHttpRequest.
  - `usb=()`: Disables the use of the USB API.
  - `xr-spatial-tracking=()`: Disables the use of spatial tracking in
  WebXR.
  - `clipboard-read=()`: Disables the ability to read from the clipboard
  .
  - `clipboard-write=()`: Disables the ability to write to the clipboard
  .
  - `gamepad=()`: Disables the use of the Gamepad API.
  - `hid=()`: Disables the use of the Human Interface Device API.
  - `idle-detection=()`: Disables the ability to detect idle state.
  - `interest-cohort=()`: Disables the use of interest cohort tracking.
  - `serial=()`: Disables the use of the Serial API.
  - `unload=()`: Disables the ability to use the `beforeunload` and
  `unload` events.
  - `window-management=()`: Disables the ability to use window
  management APIs.
2024-10-15 17:53:12 -05:00
Mrigank Badola e5ae07b1c2 chore: add cid and csec in readmes and backend docker compose yml 2024-10-09 09:45:10 +05:30
Mrigank Badola 4fbb3b59be chore: add changes in accordance to paskal/discord_poc 2024-10-09 08:57:47 +05:30
Dmitry VerkhoturovandUmputun 9fb3014229 Detect proper avatar type to return instead of returning image/* 2024-09-22 14:37:29 -05:00
dependabot[bot]andUmputun 2a9b29dd53 Bump micromatch from 4.0.5 to 4.0.8 in /site
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-20 22:31:53 -05:00
Ivan BandUmputun 872b818323 docs: fix frontend paths in translation guide (frontend/{path} -> frontend/apps/remark42/{path}) 2024-09-19 19:06:06 -05:00
Dmitry VerkhoturovandUmputun 4a7bee1d98 Fix restrictions for anonymous usernames
Tested on remark42 demo to clarify what works and what doesn't.
2024-09-19 19:05:43 -05:00
dependabot[bot]andUmputun cbe793fb42 Bump path-to-regexp from 6.2.1 to 6.3.0 in /site
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 6.2.1 to 6.3.0.
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v6.2.1...v6.3.0)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-19 19:05:22 -05:00
Dmitry VerkhoturovandUmputun cbf9a82a92 Move gcc installation before backend files addition
This allows to not reinstall apk files when backend files change and the
new image is built.
2024-09-19 19:05:01 -05:00
Dmitry VerkhoturovandUmputun 6cd5c45a6c Fix problem with logout button
The logout auth endpoint was returning no response body and type
application/json which is not valid, this commit changes it to return
plain/text instead which makes it valid.
2024-09-19 19:04:21 -05:00
adueckandUmputun 0bc85a6ff6 added RTL support 2024-08-24 18:08:09 -05:00
Tomy HsiehandUmputun 88bf4b7d70 feat: Update CLI help message 2024-08-23 19:19:59 -05:00
Tomy HsiehandUmputun 26c5425646 📖 docs: restore numbering 2024-08-23 19:19:59 -05:00
Tomy HsiehandUmputun 15d2ab9644 🕺🏻 style: Format 2024-08-23 19:19:59 -05:00
Tomy HsiehandUmputun 50c56cb771 📖 docs: Update apple integration docs 2024-08-23 19:19:59 -05:00
Tomy HsiehandUmputun e65f71b958 🛠 fix: Fix sign in with apple integration 2024-08-23 19:19:59 -05:00
Dmitry VerkhoturovandUmputun a9b439602b update go modules 2024-07-30 20:23:33 -05:00
Dmitry VerkhoturovandUmputun d2027f5241 switch playwright (e2e) to latest stable version 2024-07-30 20:23:06 -05:00
Dmitry VerkhoturovandUmputun 95966f6407 add escaping of comment text in webhook default JSON template 2024-07-01 23:41:50 -05:00
Umputun 8df986e70a Add content type check for images endpoint
A check in image proxy for validating content type of requested images added. Modified the related tests to accommodate these changes.
2024-07-01 14:47:25 -05:00
dependabot[bot]andUmputun 71a6d0b385 Bump pug from 3.0.2 to 3.0.3 in /site
Bumps [pug](https://github.com/pugjs/pug) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/pugjs/pug/releases)
- [Commits](https://github.com/pugjs/pug/compare/pug@3.0.2...pug@3.0.3)

---
updated-dependencies:
- dependency-name: pug
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-30 12:12:14 -05:00
dependabot[bot]andUmputun 974d4aaf55 Bump ejs from 3.1.9 to 3.1.10 in /site
Bumps [ejs](https://github.com/mde/ejs) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/mde/ejs/releases)
- [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-30 12:11:10 -05:00
dependabot[bot]andUmputun dc8d7d46cb Bump braces from 3.0.2 to 3.0.3 in /site
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-30 12:10:47 -05:00
dependabot[bot]andUmputun c4ace9fc0c Bump ws from 8.16.0 to 8.17.1 in /site
Bumps [ws](https://github.com/websockets/ws) from 8.16.0 to 8.17.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.16.0...8.17.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-30 12:10:26 -05:00
Dmitry VerkhoturovandUmputun 16b07ded66 fix golangci-lint reported unused parameters in example module 2024-06-30 12:07:06 -05:00
Tomy HsiehandUmputun c04705947a 📖 docs: Update README 2024-06-22 12:04:49 -05:00
Dmitry VerkhoturovandUmputun eadd65e247 update docker images, clarify comments 2024-06-05 13:05:42 -05:00
Xin LiandUmputun 4428f79046 SubscribeByEmailForm: Ensure onInput and onClick props are typed correctly.
Resolves #1771
2024-06-04 11:06:14 -05:00
Armen MkrtchyanandUmputun bad6af87f7 Update .golangci.yml
Sorted linters alphabetically, removed duplicates
2024-05-30 11:30:19 -05:00
Edward NavarroandUmputun f7ba43e5f1 Complete and update Spanish translations 2024-05-27 15:22:09 -05:00
Dmitry VerkhoturovandUmputun 661f042cb4 pin golangci-lint version to latest available, fix reported errors 2024-05-09 22:32:00 -05:00
Pavel FrancírekandUmputun 877765cda2 Update cs.json - typos
Only typo corrections.
2024-04-11 11:37:58 -05:00
Dmitry VerkhoturovandUmputun 4bb0017060 update go modules 2024-04-10 19:33:20 -05:00
Dmitry VerkhoturovandUmputun e0423b8683 fix type for value for refresh token cache
It was set to string by mistake, proper type is token.Claims.
2024-03-22 04:16:29 -05:00
Dmitry VerkhoturovandUmputun 5a781693aa hide delete button for non-admin users after edit period expires 2024-03-17 16:47:28 -05:00
Dmitry VerkhoturovandUmputun e5743185b0 collect /find Info for tree and plain types consistently
MakeTree calculated Info locally for historical reasons,
and the results were consistent with the dataService.Info call
but calculated differently.

That change fixes that, ensuring that Info is requested
in the same manner.
2024-03-16 12:49:45 -05:00
dependabot[bot]andUmputun 1510aec17c Bump google.golang.org/protobuf from 1.32.0 to 1.33.0 in /backend
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-13 18:33:05 -05:00
Dmitry VerkhoturovandUmputun 01837b69e5 fix readonly status, deleted count for plain /find request 2024-03-04 15:44:19 -06:00
Dmitry VerkhoturovandUmputun d02099844e apply prettier to site files 2024-02-25 18:27:06 -06:00
Dmitry VerkhoturovandUmputun 6fcfaa12b7 update site dependencies 2024-02-25 18:27:06 -06:00
Dmitry VerkhoturovandUmputun 6269c19881 add more tests for GET /find endpoint 2024-02-21 10:43:28 -06:00
Dmitry VerkhoturovandUmputun 1313dee829 update to lcw v2 with generic types 2024-02-20 14:15:35 -06:00
Dmitry VerkhoturovandUmputun 3210de8f7b update go modules 2024-02-20 12:23:16 -06:00
Dmitry VerkhoturovandUmputun 532573fb34 fix problems reported by golangci-lint 2024-02-20 12:10:39 -06:00
Konstantsin KryuleniaandUmputun e1173bbcad use specific pnpm version 2024-02-03 12:09:57 -06:00
Konstantsin KryuleniaandUmputun e748951182 skip lint jpeg files 2024-02-03 12:09:57 -06:00
Konstantsin KryuleniaandUmputun df8670752a copy example image to public folder 2024-02-03 12:09:57 -06:00
Dmitry VerkhoturovandUmputun 654250f033 don't load kitten picture from third party site 2024-02-03 12:09:57 -06:00
Dmitry VerkhoturovandUmputun 0050c65596 add meaningful error for lack of auth on import, remap and backup
Previously, the error printed was just the following:

error response "401 Unauthorized", Unauthorized"

New error:

error response "401 Unauthorized", ensure you have set ADMIN_PASSWD
and provided it to the command you're running: Unauthorized
2024-01-28 12:16:24 -06:00
Dmitry VerkhoturovandUmputun 02db7a917d fix lack of error on file site export
Previously, status 200 was set for file export, which is used
for backup, which resulted in an inability to set an error status code
in case of a problem with file generation.

After this change, status code 200 would be written automatically by Go
before we start writing the response's body.
2024-01-28 12:16:24 -06:00
Dmitry VerkhoturovandUmputun 81c30e01f8 cleanup images from deleted comments
Previously, images were deleted only from comments deleted
before EditDuration expiration. After this change, any deletion
of the comment deletes images if they are not used elsewhere
in comments under the same page.
2024-01-20 13:29:06 -06:00
Paul MineevandUmputun 82c617806d chore: remove theme from comment content 2024-01-18 02:56:10 -06:00
Paul MineevandUmputun e043dc2ac3 fix: break long words in code tag, combine all styles in one file, move styles to correct place 2024-01-18 02:56:10 -06:00
Dmitry VerkhoturovandUmputun cbd73865bd update go modules, update go-pkgz/auth to latest commit 2024-01-11 15:57:39 -06:00
Dmitry VerkhoturovandUmputun 884b5685eb update docker images and github CI actions 2024-01-11 01:26:30 -06:00
NavyStackandUmputun 3f14651653 fix: add missing Japanese translation 2024-01-08 11:23:03 -06:00
NavyStackandUmputun 310b797679 fix: add missing Japanese translation 2024-01-08 11:23:03 -06:00
NavyStackandUmputun 0594565143 FIX: Character escaping 2024-01-08 11:23:03 -06:00
NavyStackandUmputun d4c153662b update: Korean translation 2024-01-08 11:23:03 -06:00
NavyStackandUmputun f64b0b8831 fix: Korean missing strings 2024-01-08 11:23:03 -06:00
Vladimir DandUmputun 94893b77dc bump deps 2023-12-26 11:47:09 -06:00
Vladimir DandUmputun 30f46efa5b TLS InsecureSkipVerify option 2023-12-26 11:47:09 -06:00
Vladimir DandUmputun e0904603c6 go-pkgz/auth and go-pkgz/email modules updated, bump deps 2023-12-26 11:47:09 -06:00
Dmitry VerkhoturovandUmputun d143932924 add MIN_COMMENT_SIZE parameter 2023-12-02 12:16:26 -06:00
Dmitry Verkhoturov dcc7613409 allow disabling fancy HTML formatting
It might be necessary if the comments should preserve
original quotes instead of replacing them with angled ones.
2023-11-26 09:13:53 +01:00
Dmitry VerkhoturovandUmputun d04d2097f8 fix Commento import URL
Previously, it was not using the domain
and relying on another export format.
2023-11-20 10:59:05 -06:00
Dmitry Verkhoturov ce678bf967 fix Commento top-level comments import
Previously, top-level comments were incorrectly assigned
parent comment id "root", which made them non-root,
so they are not returned when requested
in the `/find?format=tree` API call.

To fix the previously imported comments, please export all your comments
and replace `"pid":"root"` with `"pid":""` and then re-import them.
2023-11-18 20:30:18 +01:00
Dmitry VerkhoturovandUmputun cd481d401d add tests for admin Store and DataService 2023-11-04 12:49:40 -05:00
Dmitry VerkhoturovandUmputun 618c267370 combine multiple post info in DataStore.Info instead of returning first
Previously, only the first one was returned for site-wide requests,
and now all returned information will be correctly aggregated,
and the PostInfo.URL and PostInfo.ReadOnly parameters will be dropped.
2023-11-04 12:49:40 -05:00
Dmitry VerkhoturovandUmputun 307866f7f5 simplify BoltDB.Info code
The new code does the same as the old one but doesn't call the checkFlag
in case ReadOnly is already set based on age.
2023-11-04 12:46:38 -05:00
Dmitry VerkhoturovandUmputun 19e1616129 allow title extraction only from full match of AllowedHosts
Previously, we extracted the second-level domain,
but it doesn't make sense for a list of domains defined explicitly
to display the comments.
2023-11-04 12:45:19 -05:00
dependabot[bot]andUmputun c6506b8905 Bump luxon from 2.3.0 to 2.5.2 in /site
Bumps [luxon](https://github.com/moment/luxon) from 2.3.0 to 2.5.2.
- [Release notes](https://github.com/moment/luxon/releases)
- [Changelog](https://github.com/moment/luxon/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moment/luxon/compare/2.3.0...2.5.2)

---
updated-dependencies:
- dependency-name: luxon
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-28 03:15:10 -05:00
Paul MineevandUmputun 676ae77456 update links styles 2023-10-28 01:56:04 -05:00
Paul MineevandUmputun b93fc48b73 fix types on error message extraction 2023-10-28 01:45:03 -05:00
Paul MineevandUmputun 4be664e78d fix styles around admin controls 2023-10-28 01:36:17 -05:00
Paul MineevandUmputun 62aaa35287 fix types on error message extraction 2023-10-28 01:35:58 -05:00
Dmitry VerkhoturovandUmputun 69b18d3536 fix wrapped errors checks
As errors can be wrapped in recent versions of Go, the proper way
to check the error types are `errors.As` and `errors.Is`.
2023-10-10 23:36:04 -05:00
Dmitry VerkhoturovandUmputun efceed6f68 limit TitleExtractor to allow only Remark42 whitelisted domains
Allowed domains consist of `REMARK_URL` second-level domain (or whole IP in case it's IP like `127.0.0.1`) and `ALLOWED_HOSTS`. That is needed to prevent Remark42 from asking arbitrary servers and storing the page title as the comment.PostTitle.

Previous behaviour allowed the caller of the API to create a comment
with an arbitrary URL and learn the title of the page, which might be
accessible to the server Remark42 is installed on but not to the user
outside that network (CWE-918).
2023-10-10 23:34:35 -05:00
Dmitry VerkhoturovandUmputun f4358173c7 limit TitleExtractor to allow only Remark42 whitelisted domains
Allowed domains consist of `REMARK_URL` second-level domain (or whole IP in case it's IP like `127.0.0.1`) and `ALLOWED_HOSTS`. That is needed to prevent Remark42 from asking arbitrary servers and storing the page title as the comment.PostTitle.

Previous behaviour allowed the caller of the API to create a comment
with an arbitrary URL and learn the title of the page, which might be
accessible to the server Remark42 is installed on but not to the user
outside that network (CWE-918).
2023-10-10 23:34:35 -05:00
Dmitry VerkhoturovandUmputun 7a71d47556 remove all HTML tags from comment title and username
Previously, we stripped unsafe HTML tags but left some,
but it's not expected to have a link in a title or username,
so the new behaviour is stripping everything.
2023-10-10 12:41:26 -05:00
Dmitry VerkhoturovandUmputun 41d27e2a7f clarify titles for frontend and backend configuration doc 2023-10-10 11:51:26 -05:00
Dmitry VerkhoturovandUmputun 10e4686f1a reproduce report of CWE-918 from #1677 2023-10-10 11:43:56 -05:00
sharief007andUmputun eba447319d Extract error msg from API response for preview. 2023-10-08 12:53:04 -05:00
Dmitry VerkhoturovandUmputun 40a0d7ca62 update Telegram notifications instructions
Remove outdated information about functionality which was already implemented.
2023-09-12 20:45:25 -05:00
Dzung DoandUmputun c9b6f9272f Update vi.json 2023-09-04 01:35:57 -05:00
Umputun 1f2500f16f switch to stable, updated auth 2023-08-21 11:20:24 -05:00
Umputun 4b855ceddd mod tidy for mem_store example 2023-08-20 19:37:39 -05:00
Umputun e30d4da455 update auth lib to master #1660
adopt tests for the mandatory provider check

fix leftover test for the server
2023-08-20 19:37:39 -05:00
Umputun 26e6e57949 add cmd/var to ignore 2023-08-07 13:33:04 -05:00
Umputun b572966bc4 remove cmd/var 2023-08-07 13:32:44 -05:00
Umputun bbfa4f1043 update base images for backend docker build 2023-08-07 13:12:42 -05:00
Umputun 9ad4f0b75e lint: remove deprecated rand.Seed from tests 2023-08-07 13:09:38 -05:00
Umputun 2093f4ece2 update go to 1.20, bump deps 2023-08-07 13:03:16 -05:00
Alexander GusmanandUmputun 7bc7703dc2 build: use pnpm instead of npm to install deps for size checks 2023-07-24 12:29:55 -05:00
Alexander GusmanandUmputun 0ed7452e77 feat(ui): telegram user subscriptions 2023-07-24 12:29:55 -05:00
Alexander GusmanandUmputun 366cc19c1b fix(rest): correct status code for telegram check token 2023-07-24 12:29:55 -05:00
Dmitry VerkhoturovandUmputun c72f30eabb remove proxied images from sanity check
Previously, proxied and local images were checked for presence in the
storage before previewing or posting the comment. That logic resulted in
 an inability to post with an image when a proxy for images is enabled,
 as proxied images are not downloaded to disk before the first time
 someone loads them, which could only happen after the user either
 previews or posts the message.

After this change, preview and post only checks the local images'
presence and ignore the proxied ones.
2023-07-23 12:10:52 -05:00
Justin HawkinsandGitHub 235f0dade0 Update documentation to support Caddy V2 (#1657)
* Update index.md

Change Caddy example config to the current V2 config.

* Simplify config

* Include legacy config as well
2023-07-20 21:12:11 -05:00
Dmitry VerkhoturovandUmputun 9c718cbc5f clarify SharedSecret usage by comment
It's not used aside from `server` but it was decided to keep it in place
for backwards compatibility in PR #1544.
2023-07-16 20:50:03 -05:00
Dmitry VerkhoturovandUmputun 02de92afc7 update Google auth setup instructions
Update based on my experience going through them.
2023-07-16 18:14:38 -05:00
Yury KotovandUmputun add01455fb Fix snippet generation
1) Current implementation simply removes the last word, without truncating up to limit length.
2) In case if even the first word (magnet link or some base64?) is too long don't add extra space.
2023-07-04 13:32:37 -05:00
EugeneandGitHub 497f3ce47f Skip confirmation step on email subscription (#1646)
* Email subscription params in request body

* Email subscription params in request body

fix tests

* Skip confirm step on email sub

When user logged in with the same email he tries to subscribe

* Skip confirm step on email sub

set autoConfirm param to make it work

* Update size-limit

* Handle 409: already subscribed

* refactor: prevStep to justSubscribed

prevStep is not used anywhere else and because of it influences output text (haveSubscribed), have changed it to more intuitive justSubscribed variable

* Test case for http error 409
2023-06-29 14:15:59 -05:00
LoneExileandUmputun 64188e5713 docs: added Astro w/ React/Preact Components Integration 2023-06-29 13:21:42 -05:00
Eugene OrlovandUmputun 33a6d6da97 Email subscription params in request body
fix tests
2023-06-29 10:52:36 -05:00
Eugene OrlovandUmputun 6410e3be85 Email subscription params in request body 2023-06-29 10:52:36 -05:00
Dmitry VerkhoturovandUmputun 136d7e8215 switch from telegram_bot_username to telegram_notifications
Bot username is returned as an answer to subscribe request,
so knowing it in advance is unnecessary.
2023-06-29 10:52:00 -05:00
Eugene OrlovandUmputun d3fdd7b0d8 Fix color var usage 2023-06-27 22:16:38 -05:00
Eugene OrlovandUmputun 329fcc204c Update size-limit 2023-06-27 22:16:38 -05:00
Eugene OrlovandUmputun ba2c7894a8 Telegram QR styling
- 1/1 aspect ratio
- white border around (for easier scan)
2023-06-27 22:16:38 -05:00
Eugene OrlovandUmputun 07667c8881 fix: Gap between buttons and markdown tip 2023-06-12 09:44:04 -05:00
Eugene OrlovandUmputun 68504a70a0 [site] fix: anchor offset
use different header size on mobiles and desktops
2023-06-06 15:22:02 -05:00
Eugene OrlovandUmputun 32073b3d66 [site] fix: anchor offset
make it use tailwind styles
2023-06-06 15:22:02 -05:00
Eugene OrlovandUmputun d1c1664a38 [site] fix: anchor offset
when navigating through an anchor
2023-06-06 15:22:02 -05:00
Vasilii BlazhnovandUmputun 8cbcff98ec Fixed wrong markdown 2023-05-08 14:35:30 -05:00
Dmitry VerkhoturovandUmputun 26f82ad95c don't allow relative links in comments
(url) is a text inserted by default and never an intended URL.

That additional validation will ensure that users won't post relative
links because they are rarely intended.
2023-04-09 23:30:21 -05:00
Dmitry VerkhoturovandUmputun 1b90604b2d update go modules, fix Apple auth redirect
Previously the redirect after successful authentication didn't work.
2023-04-02 03:47:35 -05:00
Dmitry VerkhoturovandUmputun a03c002df4 bump golangci-lint to latest 2023-04-02 01:21:33 -05:00
Dmitry VerkhoturovandUmputun 1ce9415d34 ping pnmp version to 7
Previously version 8 was installed once it became available, breaking
the pipelines and docker build.
2023-04-01 14:45:44 -05:00
SimonHaasandUmputun cc842901b3 updated deprecated link 2023-03-17 10:59:45 -05:00
Paul MineevandUmputun 23d7e4cdbb fix indentation for apps/remark42 2023-03-10 17:46:41 -06:00
Dejavu MoeandUmputun b48f8fca31 Fix spell errors 2023-03-10 17:21:04 -06:00
DejavuMoeandUmputun a4da93326e Improve Simplified Chinese translation
modified:frontend/apps/remark42/app/locales/zh.json
2023-03-10 17:20:45 -06:00
konstantin krivleniaandUmputun 8bd5c0d163 #1605 fix loading th locale 2023-03-10 17:20:25 -06:00
DejavuMoeandUmputun 972ab87247 Update translation of the Simplified Chinese 2023-03-08 17:22:06 -06:00
dmitry.konchalenkovandUmputun d1ea664b41 Update translations 2023-02-28 11:52:29 -06:00
Dmitry Verkhoturov a55fadd53a make docker build fail on backend test failure
Previously, the commands were combined incorrectly, and the failure
of backend tests was ignored.
2023-02-05 21:11:12 +01:00
Dmitry VerkhoturovandUmputun c70a66a1c5 bump go modules 2023-01-21 13:30:44 -06:00
Dmitry VerkhoturovandUmputun 8357846818 add test JWT token generation instructions 2023-01-15 12:54:44 -06:00
Matt JacksonandUmputun 31ea91afb8 docs: added Astro w/Svelte Components Integration 2023-01-14 19:12:38 -06:00
Dmitry Verkhoturov 6616541f65 improve frontend documentation
Variables were documented in the documentation but not in the code,
and max_last_comments needed to be documented.
2023-01-10 23:53:26 +01:00
Paul MineevandUmputun 01695822bb fix: calculate correct size when no_footer=true 2023-01-10 11:27:44 -06:00
Paul MineevandUmputun f0186d1aab fix: fix no footer param 2023-01-10 11:27:44 -06:00
Dmitry VerkhoturovandUmputun 41a3359085 add the ability to set the JWS aud per site_id
Without this option, the aud is ignored.
It works only with RPC admin storage.

The shared key returned for all requests with the default shared admin
storage, so enabling that option does not affect it.
2023-01-10 11:24:41 -06:00
Dmitry Verkhoturov d6cce8df2c cleanup of the frontend code
- replace undocumented `substr` with `substring`
- remove unused code
- inline a few variables
- simplify ifs when possible
- improve saveCollapsedComments documentation
- cleanup the unused imports
- remove unused variables and types
2023-01-09 22:16:35 +01:00
Dmitry VerkhoturovandUmputun 596861a594 don't remove the twitter-tweet class from blockquote
This is needed to format the Twitter blockquotes as tweets.
2023-01-09 03:20:54 -06:00
Dmitry VerkhoturovandUmputun 385ea800a4 don't verify subscription email once more for email users
Previous behaviour is preserved for query parameters way of requesting
the subscription. The new behaviour with the possibility to confirm
the email right away without a separate /email/confirm call is enabled
only with request params sent in the request body, which was not a thing
before 27fc339e, which was merged just now and is not part
of any tagged version yet.
2023-01-09 03:17:25 -06:00
Dmitry Verkhoturov 27fc339e36 use the request body for email subscription endpoints
Previously, the endpoints were using query parameters.
After this change, the body is tried to be parsed.
2023-01-08 23:26:06 +01:00
Dmitry VerkhoturovandUmputun 61e2173f25 add e2e tests to makefile
Also, add missing entries to .dockerignore.
2023-01-08 14:31:36 -06:00
Dmitry VerkhoturovandUmputun 13a3fc3d1b move remark42 frontend nvmrc to /frontend/ 2023-01-08 14:31:15 -06:00
Dmitry VerkhoturovandUmputun 6a1b515ea9 add anti-spam documentation
It describes how anti-spam works now and its future.
2023-01-08 14:30:55 -06:00
Dmitry VerkhoturovandUmputun 82f27e6b63 fix typos in frontend code 2023-01-08 12:30:18 -06:00
Paul MineevandUmputun 6ac75031ad fix and optimize apple icon 2023-01-07 18:16:21 -06:00
Dmitry VerkhoturovandUmputun 8b7f1331ee add Apple auth provider frontend support
With distinct logos for light and dark theme from
https://devimages-cdn.apple.com/design/resources/download/Logo-Sign-in-with-Apple.dmg
2023-01-07 18:16:21 -06:00
Dmitry VerkhoturovandUmputun 099aad8475 add apple bad key test, fix key location
Previously, default location was outside of container mount.
2023-01-04 03:54:38 -06:00
Dmitry VerkhoturovandUmputun c1b3fba344 add backend support for Apple auth provider
It's a bit different from other OAuth providers and requires a
different set of options and a private key file.
2023-01-03 23:47:42 -06:00
Dmitry VerkhoturovandUmputun d7e9be99f9 make Close() calls idempotent
Previously, few of them resulted in panics when called more than once.
2023-01-03 01:41:26 -06:00
Dmitry Verkhoturov 067a8bcb21 make the email token tooltip more informative
Previously it said just "Token", but now it will provide more explicit
instructions about copying and pasting the token received by email.

Resolves #1339
2023-01-03 10:52:17 +04:00
Umputun f5569a62f1 add local analytic support 2022-12-25 18:45:31 -06:00
Jakub FridrichandUmputun 1ab1ed8a82 Added cs lang 2022-12-16 12:00:57 -06:00
Pavel MineevandUmputun 8d95baa70f chore: add resize observer mock 2022-12-05 12:03:36 -06:00
Pavel MineevandUmputun 050bce163a refac: use resize observer instead mutation observer in order to resize iframe 2022-12-05 12:03:36 -06:00
Pavel MineevandUmputun 68b84683d0 fix: update iframe size on textarea size change 2022-12-03 12:08:00 -06:00
Pavel MineevandUmputun 6fe373d540 fix: update fe contributing documentation and get dev commands in order 2022-11-30 01:19:51 -06:00
Pavel MineevandUmputun ba19bcc729 fix: update iframe size on mount 2022-11-29 18:12:14 -06:00
Dmitry VerkhoturovandUmputun f1b65db2c7 prefer unused PreferServerCipherSuites param
This is not used since 1.17,
https://github.com/golang/go/commit/9d0819b27ca248f9949e7cf6bf7cb9fe7cf574e8
2022-11-13 12:02:40 -06:00
Dmitry VerkhoturovandUmputun f5f287ef06 bump actions/setup-go to v3 to resolve node12 deprecation note 2022-11-13 12:00:54 -06:00
Igor KlipachandUmputun f161e6033c Removed duplicated linter 'megacheck' 2022-11-13 11:54:26 -06:00
Dmitry VerkhoturovandUmputun c86bff8811 remove duplicate type definitions from function signatures
I haven't found a linter for these, so I had to catch these manually.
I found #757 to fix one of these, and I thought it would be good
to fix everything at once.
2022-11-13 11:51:51 -06:00
Dmitry VerkhoturovandUmputun 596b1045bd allow dots in site id during email user validation 2022-11-03 16:31:00 -05:00
Dmitry VerkhoturovandUmputun 907ca2b590 remove the only generic logger usage
github.com/go-pkgz/lgr should be used instead for consistency
with the rest of the code
2022-11-03 16:30:10 -05:00
Dmitry VerkhoturovandUmputun f1b5469b83 bump github actions versions
Old ones produce warnings due to deprecation of NodeJS 12
2022-11-03 15:20:30 -05:00
Denis HananeinandUmputun 984fbde540 Support no_footer option 2022-10-25 21:42:08 -05:00
Dmitry VerkhoturovandUmputun 2bdc05dd47 remove unused entrypoint.sh
It should be removed after changes in 141c75401.
2022-10-23 12:55:34 -05:00
Dmitry VerkhoturovandUmputun d2ea572abf fix "it's" used in place of "its" 2022-10-20 14:18:21 -05:00
Dmitry VerkhoturovandUmputun 8d5c4cd578 make docker build work on tag again 2022-10-20 14:16:38 -05:00
Dmitry VerkhoturovandUmputun dd1ba9b518 clean up comment HasReplies cache on child comment deletion
Previously, the cache kept the entry and deletion of the parent comment
after child deletion was not possible for the rest
of cache life (5m) duration. Now it's possible to delete
a parent comment after the deletion of the child comment
by a user or admin.

Resolves #1481
2022-10-03 03:28:29 -05:00
Dmitry VerkhoturovandUmputun cebe929118 bump go modules, enable LoginAuth option for email
That option is needed for outlook.com and Office 365, resolves #1472.
2022-10-03 03:26:34 -05:00
Dmitry VerkhoturovandUmputun 3eccf01f1f bump docker-compose.yml for the site to work with dev properly
Previously, an image built for the `build` service was then used
for `server`, and changes were invisible to the user
before the container rebuild.

After that change, the useless static `build` service is deleted,
Dockerfile is only used in the CI pipeline, and only the `server`
service is left in docker-compose for the user to test
and see documentation changes locally in real-time.

Resolves #1178
2022-10-02 23:52:19 -05:00
Dmitry VerkhoturovandUmputun 050f1b7941 migrate from mockery to moq 2022-10-02 21:21:28 -05:00
Pavel MineevandUmputun 53cc370727 admin: fix block period 2022-10-02 13:40:58 -05:00
Paul MineevandUmputun 363e05d580 fix iframe resize on auth dropdown opening 2022-10-01 18:34:33 -05:00
Dmitry VerkhoturovandUmputun 2ecc80e18c fix RPC engine work with ListFlags method
Fixes the following conversion problem for BlockedUser:

```
panic: interface conversion: interface {} is map[string]interface {},
not store.BlockedUser [recovered]
```

Resolves #1475.
2022-09-29 11:56:12 -05:00
Dmitry VerkhoturovandUmputun 0728b28856 bump golangci-lint, fix discovered problems
Also, improve the goveralls installation method.
2022-09-28 18:11:09 -05:00
Dmitry VerkhoturovandUmputun a6a9270f63 apply pngcrush, advpng and optipng to all png images
Commands used:

```
find . -type f -iname "*.png" -exec advpng -z4 {} \;
find . -type f -iname "*.png" -exec optipng -o7 -preserve {} \;
find . -type f -iname "*.png" -exec pngcrush -rem allb -brute -reduce -ow {} \;
```
2022-09-28 16:05:03 -05:00
Dmitry VerkhoturovandUmputun 372429a9f5 call admin store with a proper key
Previously it was set to fixed strings, likely a test artefact.

Resolves #1499.
2022-09-28 16:04:19 -05:00
Umputun 4733ad8eb1 Revert "fix iframe resize on auth dropdown opening"
This reverts commit 8e96aa26b4.
2022-09-21 15:57:41 -05:00
Paul MineevandUmputun 8e96aa26b4 fix iframe resize on auth dropdown opening 2022-09-21 10:56:34 -05:00
Dmitry VerkhoturovandUmputun c0e8520d31 fix makefile and dockerfile
Here are multiple changes to commands run by Makefile based on my
attempts to run it on an Oracle Linux machine with the latest Docker.
2022-09-17 13:05:56 -05:00
Paul MineevandUmputun 345f80d90d add contribution links in readme 2022-09-14 14:43:58 -05:00
Paul MineevandUmputun ffcef2fe99 fix selected item 2022-09-13 10:54:13 -05:00
Dmitry VerkhoturovandUmputun e77dc33333 bump mockery from v1.1.2 to v2.14.0
Command-line params changed their names,
and old ones won't work anymore.
2022-09-12 17:22:34 -05:00
kaikunzandUmputun 5fee19f6c7 Update supportedLocales.json
add th
2022-09-12 17:20:51 -05:00
kaikunzandUmputun 659c623240 th.json
th
2022-09-12 17:19:14 -05:00
Dmitry VerkhoturovandUmputun 695d0ea13a allow dashes in site ID when validating email auth request
As discovered in #1477, dashes are expected to work in the site ID
and do work everywhere but in email auth. That change makes
the behaviour consistent: site ID now allows dashes.
2022-09-12 17:16:22 -05:00
Pavel MineevandDmitry Verkhoturov 0f7ac514fb add e2e tests 2022-09-10 11:00:34 +02:00
Dmitry Verkhoturov 9ad3be2e97 bump go modules, make auth dev hostname customisable
After this commit, dev auth would start working with the `REMARK_URL`
hostname instead of the previously hardcoded 127.0.0.1.

Breaks development setup where `REMARK_URL` was set
to a non-standard value and dev auth was running on 127.0.0.1
and working, as, after that change, it would stop working.
2022-08-26 23:35:57 +02:00
Dmitry Verkhoturov 2c36fab8aa add compose files to dockerignore, remove defaults from frontend compose
Before that change, docker would create a new image
on docker-compose file changes.

Frontend docker-compose file change removes options set
to the same values as their default values.
2022-08-26 23:35:40 +02:00
Avinal KumarandUmputun 3b7a4b6e52 Fix directory name in CODEOWNERS file
Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
2022-08-25 01:10:14 -05:00
Paul MineevandUmputun 499302c48e fix broken icons 2022-08-24 11:37:30 -05:00
Dmitry VerkhoturovandUmputun 28fbe76547 remove duplicated docker-compose.yml from parameters doc
Also, clarify the wording on TIME_ZONE parameter a little.
2022-08-23 22:39:02 -05:00
Paul MineevandUmputun fabb31275d FE codeowners 2022-08-23 22:18:04 -05:00
Dmitry VerkhoturovandUmputun 96b75af027 proper grammar for ADMIN_PASSWD parameter notes 2022-08-23 21:22:32 -05:00
Pavel MineevandPaul Mineev dc048ef047 fix: anon cannot request data removal 2022-08-22 11:42:15 -07:00
Pavel MineevandUmputun d6b2960a4a fix: save choosen color scheme 2022-08-21 22:36:59 -05:00
Pavel MineevandUmputun b47b6c4f56 add api sdk 2022-08-20 12:58:40 -05:00
Umputun 75427df7de make translated lines in ru shorter as a workaround for ugly formatting 2022-08-19 14:24:54 -05:00
Umputun cb98885e1f add warn log on rejected email auth validation https://github.com/umputun/remark42/discussions/1139#discussioncomment-3409701 2022-08-16 17:10:09 -04:00
Paul Mineev 7e0445cc94 disable color-scheme on remark iframe 2022-08-10 10:20:31 -07:00
Dmitry VerkhoturovandUmputun a9836aaea0 move static web files from rakyll/statik to go:embed
There is no need for the rakyll/statik package starting with Go 1.16,
which provides us with tools for embedding files
without third-party libraries.
2022-08-03 15:41:02 -05:00
crazyandUmputun 27b28dba0d add lose commit Traditional Chinese locale file 2022-08-01 10:53:39 -05:00
Dmitry Verkhoturov 86d059bf99 move templates from rakyll/statik to go:embed
There is no need for the rakyll/statik package starting with Go 1.16,
which provides us with tools for embedding files
without third-party libraries.
2022-07-29 19:07:49 +02:00
Paul MineevandUmputun 76f5ce32ca add turborepo 2022-07-29 11:05:21 -05:00
Dmitry VerkhoturovandUmputun ff9eab998a bump buildgo to 1.9.2 to be consistent with other baseimage usages 2022-07-28 16:41:15 -05:00
Dmitry VerkhoturovandUmputun ac3a36eb13 rewrite email documentation, elaborate on how to replace templates
I've run Grammarly over that text to find and fix some flaws
and added more detailed instructions on replacing a built-in template
with your own.
2022-07-28 13:28:27 -05:00
Dmitry VerkhoturovandUmputun 4b4c749756 remove mod=vendor from go build directives as it's no longer actual
It's a default in the presence of the vendor folder since Go 1.14,
https://go.dev/ref/mod
2022-07-27 22:09:14 -05:00
Dmitry VerkhoturovandUmputun 4777f4059f hardcode dockerhub username and ghcr.io repo for site docker push
Currently, such a build most likely has access to secrets but
fails due to the wrong username logging with DockerHub when
rebase is done by anyone but @umputun.

I've missed that last case in 5e5b3e0 and ad5d555.
2022-07-27 22:08:10 -05:00
Dmitry Verkhoturov ad5d555ac8 hardcode dockerhub username and ghcr.io repo for site docker push
Currently, such a build most likely has access to secrets but
fails due to the wrong username logging with DockerHub when
rebase is done by anyone but @umputun.
2022-07-28 01:34:21 +02:00
Dmitry Verkhoturov fc7540fc9b do not run size limit job outside of PRs
Currently, this step emits the
"Error: No PR found. Only pull_request workflows are supported."
message when run on the master branch commits (after the merge),
so the change prevents it from being run there.
2022-07-28 01:34:05 +02:00
Dmitry Verkhoturov 63a2bdea48 get rid of getstarted.html mentions
umputun introduced that reference in 70649b271,
but I can't find any references to it or the file itself now.
2022-07-27 23:22:08 +02:00
Dmitry Verkhoturov 41d47fdb3e don't enable telegram user notifications when they are disabled
Before that change, enabling Telegram auth
also enabled Telegram user notifications.
2022-07-27 23:21:09 +02:00
Paul Mineev 86dae37c5d docs: improve frontend dev instructions 2022-07-27 10:39:10 -07:00
Dmitry Verkhoturov 2a97d9379e fix unsubscribe template filename 2022-07-27 19:02:34 +02:00
orangesobeautifulandUmputun ba0060316c add Traditional Chinese (zh-tw) translations 2022-07-27 11:43:45 -05:00
Paul MineevandUmputun 41f18a23e5 docs: link backend dev docs to readme.md 2022-07-25 16:39:11 -05:00
Paul MineevandUmputun bf26ad4acc link readme for frontend dev 2022-07-25 16:39:11 -05:00
Paul MineevandUmputun acc56ad42b run dev env for site in docker 2022-07-25 13:29:33 -05:00
Paul MineevandUmputun 922e779118 docs: reorg contributing section 2022-07-25 13:23:36 -05:00
Dmitry VerkhoturovandUmputun f104a6e1b6 move common options to cmd.go
Timeout, admin password and site id are set in many commands,
and we need to take care of synchronising the descriptions
and flags between them.

This change moves these standard options to cmd.go importing them
in the same manner CommonOpts imported by all commands already.
2022-07-25 12:42:13 -05:00
Dmitry VerkhoturovandUmputun 0aa6052eba update backup documentation
Few things here:
1. Merge automatic and manual backup to a single page
2. State that ADMIN_PASSWD must be enabled for backup or restore to work
3. Remove unneeded usage of --admin-passwd from commands
  inside the container
4. Make the main backups page (not clickable through the interface,
  available only in search) redirect to information about backups
  instead of displaying text
5. Add HTTPS port to canonical docker-compose.yml
6. Clarify build option in the canonical docker-compose.yaml
2022-07-25 12:09:42 -05:00
RobinandUmputun f25b34d5e2 docs(manuals): add ts version of react component
Some time ago Gatsby started to support Typescript natively. Also the frontend of remark42 is written in Typescript. So I thought it's a good idea to add a Typescript version of the component.
2022-07-25 12:02:08 -05:00
Dmitry VerkhoturovandUmputun 5e5b3e0830 hardcode dockerhub username and ghcr.io repo for docker build
Currently, such a build most likely has access to secrets but
fails due to the wrong username logging with DockerHub when
rebase is done by anyone but @umputun.
2022-07-24 16:43:38 -05:00
Dmitry Verkhoturov f1163139d7 add docker image labels, add docker image badge to readme
Specification for labels:
https://github.com/opencontainers/image-spec/blob/main/annotations.md
2022-07-24 23:06:42 +02:00
Paul MineevandUmputun 5db6e4364a fix simple mode 2022-07-21 22:24:32 -05:00
Paul MineevandUmputun 3d1a3fd7cf update ci workflows 2022-07-21 19:52:48 -05:00
Paul MineevandUmputun 6625face50 update dev docs 2022-07-21 18:27:33 -05:00
Paul MineevandUmputun d9764c251d workspaces 2022-07-21 18:27:33 -05:00
Dmitry Verkhoturov 243c8356e7 do not sanitise the original comment markdown
Previously it was sanitised using the HTML sanitiser,
but it had proven troublesome and unnecessary.
Remark42 rendered the markdown into proper HTML, but then some pieces
of it (like cited HTML code inside the code block, marked by backticks)
were cut out, which then showed the incorrect markdown to a user when
they were editing the comment.

For example, the comment "`foo<bar>`" became "foo" after sanitising,
and despite the proper render user saw only "foo" when editing
the comment.

After this change, the initial comment markdown is preserved unaltered.
It could contain dangerous HTML with JS, which I assume shouldn't
be a problem as it's never rendered as HTML but instead supposed
to be converted to HTML by the interpreter. In Remark42, it's stored
in a comment.Text field and sanitised and thus safe.

I've left information about the potential danger of rendering
the original markdown as-is without an interpreter in
all relevant places I could find.
2022-07-22 01:08:08 +02:00
Dmitry VerkhoturovandUmputun 2d2f2ab02a return docker build for frontend and backend code outside in CI
Previously we built a Docker image just for the test,
but the introduction of multi-arch build in 9fbf0952
build also meant the push of the image, so it was
restricted only to the master branch.
This change re-introduces the Docker image build
outside the master branch, which is helpful
in pull requests.

We recently had a few frontend PRs which broke
the Docker image build silently, and that change
prevents it from happening.
2022-07-19 19:25:54 -05:00
Dmitry VerkhoturovandUmputun 50785e0577 bump didip/tollbooth from v6 to v7 2022-07-19 17:13:02 -05:00
Dmitry VerkhoturovandUmputun 9c1a827685 update alecthomas/chroma and Depado/bfchroma to v2 2022-07-13 20:29:46 -05:00
Paul MineevandUmputun 8c658b7eda fix frontend test/build in docker 2022-07-13 14:35:54 -05:00
Paul MineevandUmputun 26d8d3daee use pnpm lock 2022-07-13 14:00:48 -05:00
Dmitry VerkhoturovandUmputun 26476db95d update go-pkgz/rest, stretchr/testify, three stdlib modules 2022-07-13 12:40:30 -05:00
Pavel Mineev 3c90f6ae61 increase size-limit 2022-07-12 13:34:44 -07:00
Pavel Mineev 4889afdf0c use size-limit/file 2022-07-12 13:34:44 -07:00
Paul MineevandPaul Mineev 2e777ea752 switch to pnpm 2022-07-11 20:13:39 -07:00
Paul MineevandPaul Mineev fddf1e21f3 prettier 2022-07-11 16:21:44 -07:00
Paul MineevandPaul Mineev d6013b10e6 separate persist logic from compnennt 2022-07-11 16:21:44 -07:00
Pavel MineevandUmputun aac6af40cc update height when an image is loaded 2022-07-11 12:54:48 -05:00
Dmitry VerkhoturovandUmputun 1f96a0e4d3 update go-pkgz/auth module to fix dev provider work
Fix for error introduced in the following commit:
https://github.com/go-pkgz/auth/commit/06e72788bcbb23d958c60655b42892b95457477e

After text/template was replaced with the html/template,
the dev provider started escaping parameters
which were not supposed to be escaped.
2022-07-10 10:49:41 -05:00
Paul MineevandUmputun 99716984ad up node version, add nvmrc, recommend nvm 2022-07-02 11:48:22 -05:00
Paul MineevandUmputun 24e9404a6f Fix email autofill in subscription popup 2022-07-01 12:33:15 -05:00
Paul MineevandUmputun 1f1adba5fd up eslint 2022-07-01 12:22:57 -05:00
Paul MineevandUmputun b907354746 update size limit for remark chunk 2022-07-01 12:22:57 -05:00
Paul MineevandUmputun 9a08d4a412 fix problems after update 2022-07-01 12:22:57 -05:00
Paul MineevandUmputun 3a08e6dd55 bump prod deps 2022-07-01 12:22:57 -05:00
Paul MineevandUmputun 02f782a5ab use ts for jest config, use transformIgnorePatterns, jsdom env by default 2022-07-01 12:22:57 -05:00
Paul MineevandUmputun 30d68b2d1e update eslint config 2022-07-01 12:22:57 -05:00
Paul MineevandUmputun 8974cde582 bump dev deps 2022-07-01 12:22:57 -05:00
Paul MineevandUmputun 5f3206a4e8 preact preset already includes rules for jest 2022-07-01 11:13:28 -05:00
Paul MineevandUmputun 40e38d225e fix broken deps 2022-07-01 11:13:28 -05:00
Dmitry VerkhoturovandUmputun a73072c8fb add documentation on running remark42 on a separate domain 2022-06-30 19:12:00 -05:00
Dmitry VerkhoturovandGitHub 6a5c5a4c08 add missing env_delim to ALLOWED_HOSTS parameter (#1395) 2022-06-29 22:32:48 -05:00
Dzung DoandGitHub fa7d5cee87 Translate some string into Vietnamese (#1381)
* Finish Vietnamese translation and editing some typo missing
* Update vi.json
2022-06-07 10:45:41 -05:00
Paul MineevandUmputun fe4db30e6d show subscription buttons in simple view, add ability to hide rss button 2022-06-06 10:00:24 -05:00
Paul MineevandUmputun 6936268fd2 fix basepath for oauth icons 2022-06-05 17:46:31 -05:00
Umputun e55f6ffdf3 go mod tidy for examples 2022-06-05 12:53:34 -05:00
Umputun e182e3c776 switch to master version of auth
for https://github.com/go-pkgz/auth/pull/119
2022-06-05 12:49:30 -05:00
UmputunandGitHub 6309443d1f removes .git from build layer, emulates GH build (#1375)
* removes .git from build layer, emulates GH build #269

* rundev target with git version passed in
2022-06-05 12:36:18 -05:00
Umputun 6f81bf00e8 add validation for email and site 2022-06-05 11:57:51 -05:00
Umputun 86a1f5ee5d exact match on email login path 2022-06-05 11:57:51 -05:00
Umputun b3e460eebd sleep in anon test to prevent limiter 2022-06-05 11:57:51 -05:00
Umputun 5121c48c31 reduce max limiter for /auth to 2r/s 2022-06-05 11:57:51 -05:00
Umputun 5f8e16cbe2 add email auth validation with middleware 2022-06-05 11:57:51 -05:00
Ruslan NagimovandUmputun 12e4f283fe typo 2022-06-02 10:47:06 -05:00
Dzung DoandUmputun 20ca0896a6 Finish Vietnamese translation
and editing some typo missing
2022-05-30 13:39:45 -05:00
Dmitry VerkhoturovandUmputun 3b5f44da46 bump go modules, fix StartTLS email notifications
In #1359, we discovered that StartTLS was not working\
due to the wrong host passed. This bumps the library for the fix.

Also, after a switch to go-pkgz/notify MailGun email sending
broke due to the difference in the destination email parsing,
the fix is also applied after this commit.
2022-05-20 16:00:30 -05:00
Dmitry VerkhoturovandUmputun 9049d7a616 add missing RPC type of image storage to the documentation 2022-05-15 15:56:53 -05:00
Dmitry VerkhoturovandUmputun adb77d9a11 sync server parameters with their description 2022-05-12 11:41:52 -05:00
Paul MineevandUmputun 18cc34535f fix lint and test 2022-05-10 14:47:34 -05:00
Paul MineevandUmputun 9053668cfe fix editing mode 2022-05-10 14:06:02 -05:00
Alena MaslovaandUmputun b82ed825f7 add comment about custom ID user generation 2022-05-10 12:59:49 -05:00
Alena MaslovaandUmputun ac492180e8 use custom UserIDFunc for anonymous provider 2022-05-10 12:59:49 -05:00
dmitry.konchalenkovandUmputun 168a6d36c0 Update translations 2022-05-09 02:52:06 -05:00
UmputunandGitHub 7cdb006f81 Links rune (#1344)
* shorten links with non-latin properly

* lint: unneeded conversion
2022-04-30 12:03:52 -05:00
dependabot[bot]andUmputun 5a6ca7a82b Bump ejs from 3.1.6 to 3.1.7 in /site
Bumps [ejs](https://github.com/mde/ejs) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/mde/ejs/releases)
- [Changelog](https://github.com/mde/ejs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mde/ejs/compare/v3.1.6...v3.1.7)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-29 13:34:05 -05:00
Dmitry VerkhoturovandUmputun a980bdbad2 switch to go-pkgz/notify package: email 2022-04-29 13:32:15 -05:00
Dmitry VerkhoturovandUmputun 0d9c80aec7 switch to go-pkgz/notify package: slack 2022-04-29 13:32:15 -05:00
Dmitry VerkhoturovandUmputun 59fb68ab2d switch to go-pkgz/notify package: telegram 2022-04-29 13:32:15 -05:00
Dmitry VerkhoturovandUmputun ad0ac693de switch to go-pkgz/notify package: webhook 2022-04-29 13:32:15 -05:00
Dmitry VerkhoturovandUmputun 0560a893cf switch to go-pkgz/notify package: modules 2022-04-29 13:32:15 -05:00
Umputun 598d767791 resolves #415 and the similar issue with title 2022-04-29 10:52:05 -05:00
Dmitry VerkhoturovandUmputun 0c3053d4ad CloseIdleConnections on http clients
Without this, go.uber.org/goleak reports
leaking goroutine caused by HTTP client
on many tests when ran one by one.
2022-04-29 10:51:49 -05:00
Dmitry VerkhoturovandUmputun 91b9324080 cleanup test files 2022-04-29 10:51:49 -05:00
Dmitry VerkhoturovandUmputun ba86db1263 replace errors package with fmt.Errorf
https://gist.github.com/Peltoche/60b8b81dfbf70164d0e2b88988003229
was used for it, thanks to @Peltoche for publishing it.
2022-04-26 00:25:09 -05:00
Paul MineevandUmputun 21d0339d3d restore low_score comment state 2022-04-24 20:19:13 -05:00
Paul MineevandUmputun 9e30e3bd2c enable downvoting for only positive 2022-04-24 20:19:13 -05:00
Paul MineevandUmputun bf6a08bdcd Move back last comments css file 2022-04-21 17:43:35 -05:00
Umputun 3f3bfaffc6 Merge remote-tracking branch 'origin/master' 2022-04-18 00:03:59 -05:00
Umputun 9efcc9aba3 add removal info to policy 2022-04-18 00:03:53 -05:00
Dmitry VerkhoturovandUmputun 2a7966b9e6 replace numbers with proper HTTP status codes in tests
Also, remove unneeded whitespaces using whitespace
linter for golangci-lint.
2022-04-17 19:42:45 -05:00
Umputun 9ab27da343 update deps on example 2022-04-15 20:29:14 -05:00
Umputun f4856c86d7 update deps 2022-04-15 12:50:05 -05:00
dependabot[bot]andUmputun 53df70bcef Bump async from 2.6.3 to 2.6.4 in /frontend
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: async
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-15 12:40:55 -05:00
Paul MineevandUmputun 2d3677d629 clean testid in production build 2022-04-15 12:39:26 -05:00
Paul MineevandUmputun 58b0841360 update comment actions block 2022-04-15 10:32:01 -05:00
Paul MineevandUmputun cd7a2a3c73 fix light bg on dark theme 2022-04-13 22:06:12 -05:00
Paul MineevandUmputun c58e6471ff recover translation for controversion string 2022-04-13 12:54:55 -05:00
Paul MineevandUmputun a840dd9081 increce size limit for last-comments widget 2022-04-13 12:51:38 -05:00
Paul MineevandUmputun 0e4ae6e050 rewrite vote component 2022-04-13 12:51:38 -05:00
Paul MineevandUmputun a290d906ee fix copy for latest safari 2022-04-13 12:15:31 -05:00
Paul MineevandUmputun 000d64d78f fix broken verify icon on admin view and optimize the icon 2022-04-13 03:40:39 -05:00
Paul MineevandUmputun c987513886 add icon for paid patreon sub 2022-04-13 03:40:39 -05:00
Paul MineevandUmputun 001b994e73 replace ts-jest and babel-jest to swc/jest 2022-04-13 03:40:19 -05:00
Paul MineevandUmputun 5ed9e9d41b update size-limit and move to app preset metrics 2022-04-12 12:18:56 -05:00
Paul MineevandUmputun 5de9544337 update max size limit for counter.js 2022-04-10 15:35:31 -05:00
Paul MineevandUmputun a320ae03ae Prevent code execution from the query 2022-04-10 15:35:31 -05:00
Paul MineevandUmputun fe6b119254 fix xss from iframe name 2022-04-10 15:35:09 -05:00
Paul MineevandUmputun 145b4c474c use default system font 2022-04-10 15:34:54 -05:00
Paul MineevandUmputun f7bddd757d Improve frontend docs 2022-04-10 11:18:15 -05:00
Paul MineevandUmputun d89c9a1d1c make content width on docs pages wider 2022-04-09 19:22:25 -05:00
Dmitry VerkhoturovandUmputun 0eac76ce6c improve notifications description in parameters doc 2022-04-07 18:27:39 -05:00
Dmitry VerkhoturovandUmputun 977e5f018b renew Google auth documentation 2022-04-07 17:14:56 -05:00
Dmitry VerkhoturovandUmputun 38a680ec4c use remark42.mysite.com consistently in documentation 2022-04-07 17:14:56 -05:00
Dmitry VerkhoturovandUmputun 7af30852db add variables into auth instructions 2022-04-07 17:14:56 -05:00
Dmitry VerkhoturovandUmputun 0047fa057f add missing main component to frontend config examples 2022-04-06 18:40:27 -05:00
Dmitry VerkhoturovandUmputun 7d4ba01409 improve getting started installation instructions 2022-04-06 17:33:34 -05:00
Umputun 65e3a82d8b add no-signature mode suppressing app info middleware #1305 2022-04-05 12:08:44 -05:00
UmputunandGitHub 89dc8ac6dd Go 1.17 (#1306)
* change go mod to 1.17

* update go-pgkz and transitive deps

* bump examples to go-1.17

* bump deps
2022-04-05 11:50:45 -05:00
konstantin krivleniaandUmputun 5ed7f27d5e update stylelint 2022-04-03 12:03:01 -05:00
dependabot[bot]andUmputun 4e0c41cf86 Bump minimist from 1.2.5 to 1.2.6 in /site
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-01 13:18:30 -05:00
itsmejoeeeyandUmputun 86b0e8d57c Remove emoji from warning in Twitter section in auth docs 2022-03-25 23:09:39 -05:00
itsmejoeeeyandUmputun d3fe35bdf2 Add warning to Twitter section in docs Authorization page 2022-03-25 23:09:39 -05:00
Dmitry VerkhoturovandUmputun 39c141c98d increase timeout for backup and restore and remap 15m->60m
Resolves #1297
2022-03-25 15:55:37 -05:00
dependabot[bot]andPaul Mineev d46c5c0c24 Bump nanoid from 3.1.23 to 3.2.0 in /frontend
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.23 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.23...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-25 21:29:03 -06:00
dependabot[bot]andUmputun 0ba8ecdf82 Bump prismjs from 1.26.0 to 1.27.0 in /site
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.26.0 to 1.27.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.26.0...v1.27.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-25 20:16:27 -06:00
mstfelgandUmputun 08903ac6f6 add arabic locale 2022-02-25 14:20:14 -06:00
Dmitry VerkhoturovandUmputun 932c0e8af0 optimise images for smaller size 2022-02-24 16:54:03 -06:00
Dmitry VerkhoturovandUmputun d47133c004 add admin UI documentation, fix title for no-subdomain doc 2022-02-24 16:54:03 -06:00
Dmitry VerkhoturovandUmputun cfb7361adf optimise images for smaller size 2022-02-24 16:18:28 -06:00
Dmitry Verkhoturov d4481d145f fix remark_config reference in the installation documentation 2022-02-23 09:12:21 +01:00
Dmitry VerkhoturovandUmputun 2ef1cfe1ea fix logic of detecting deprecated notify type params 2022-02-19 02:43:34 -06:00
Pavel MineevandUmputun 9216d4189e add telegram translation string 2022-02-18 15:59:29 -06:00
Paul MineevandUmputun 286e4c8a43 update telegram qr size 2022-02-15 18:37:24 -06:00
Dmitry VerkhoturovandUmputun f03fcf3fcf make QR for telegram borderless 2022-02-15 18:33:51 -06:00
Dmitry VerkhoturovandUmputun f448208475 allow skipping frontend build in Docker
That option allows having backend-only build,
skipping the long frontend build and test step.
Frontend developers run NodeJS locally and usually
don't need to have frontend built inside the docker image.
2022-02-15 18:30:38 -06:00
dependabot[bot]andUmputun 214e183b07 Bump follow-redirects from 1.14.7 to 1.14.8 in /site
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-15 00:19:36 -06:00
dependabot[bot]andPaul Mineev 338059e507 Bump follow-redirects from 1.14.7 to 1.14.8 in /frontend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-15 00:18:45 -06:00
konstantin krivleniaandUmputun 188e3974bc add support node 17 2022-02-13 23:33:41 -06:00
Dmitry VerkhoturovandUmputun b5f34b8777 remove notice about user notifications in telegram 2022-02-12 03:52:16 -06:00
Umputun 9d8985e616 change update to silent curl 2022-02-11 12:00:55 -06:00
Paul MineevandUmputun 9bae155e79 Fixes for telegram
- fix type for an error in fetcher
- use is-object func for checks
- better error handling for auth requests
2022-02-11 10:59:19 -06:00
Umputun 7b47bd2f05 return 200 and empty list on user comments if nothing #1265 2022-02-10 15:31:09 -06:00
UmputunandGitHub 74b47d3a8a Merge pull request #1107 from Ksinia/master
Add telegram auth to frontend
2022-02-09 11:05:44 -06:00
Dmitry VerkhoturovandUmputun 6fd730655a bump Go modules 2022-02-09 11:04:13 -06:00
Pavel Mineev c9b1fb4737 simplify click outside 2022-02-07 22:47:00 -06:00
Pavel Mineev 38414c4628 move error hook to hooks file 2022-02-07 22:36:36 -06:00
Pavel Mineev b97b1f9462 telegram adjustments and tests
- get rid of redux store in favor of local state
- add tests for telegram happy path
- lift auth handling on Auth level
2022-02-07 22:28:19 -06:00
dependabot[bot]andPavel Mineev 7395198843 Bump nanoid from 3.1.23 to 3.2.0 in /frontend
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.23 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.23...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-07 22:28:07 -06:00
dependabot[bot]andUmputun 988391bc9d Bump nanoid from 3.1.23 to 3.2.0 in /frontend
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.23 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.23...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-07 19:52:35 -06:00
Ksinia 5c91b8764e Switch QR to backend rendering 2022-02-08 00:28:34 +01:00
Ksinia 7c6b44a5bb Get rid of isTelegramShown state 2022-02-07 23:33:32 +01:00
Ksinia 0f4ac6348c Fix function name 2022-02-07 23:33:32 +01:00
Pavel MineevandKsinia 0ea91bb99f update tg icon 2022-02-07 23:33:32 +01:00
Pavel MineevandKsinia 3e12ca4074 fix tests 2022-02-07 23:33:32 +01:00
Ksinia 69b110e36b Add telegram auth to frontend 2022-02-07 23:33:32 +01:00
Dmitry VerkhoturovandUmputun 9dbc36e426 remove explicit /srv/ usage from docker-compose files
We have plenty of paths used in the application, but two of them
are hardcoded in examples all over the code for historical reasons.

I found that by default in Docker, the path would resolve to the value
we are setting it explicitly to, so it doesn't make sense to set
a few variables we are setting now explicitly.
2022-02-07 03:54:54 -06:00
JoeyZhouAusandUmputun e0980d4700 Fix typo in index.md
The text in the backend development section should be backend instead of frontend
2022-02-06 21:30:01 -06:00
Dmitry VerkhoturovandUmputun f58073fa57 add proper default values for two boolean variables 2022-02-06 16:37:47 -06:00
Dmitry VerkhoturovandUmputun 6fe83fb6b8 bump auth module
Follow-up for https://github.com/go-pkgz/auth/pull/107
2022-02-06 14:22:27 -06:00
Dmitry VerkhoturovandUmputun 49d2552a78 fix frontend documentation typo 2022-02-06 13:55:52 -06:00
RoganikandUmputun 78be64900b Add tips to url-migration docs 2022-02-06 12:42:32 -06:00
Dmitry VerkhoturovandUmputun 603decabf6 add QR API endpoint for Telegram auth and notifications
Telegram authentication requires you to open a chat on the phone.
It's convenient to have a QR code for the case when you want to
log in on the computer but have Telegram only on your phone
and would be able to scan the QR instead of copy-pasting the link
from the computer to the phone any other way.

Originally we thought of generating QR on the client but found
backend-generated QR a better alternative because we avoid adding
one more JavaScript dependency to the frontend that way.
2022-01-31 14:28:18 -06:00
Dmitry VerkhoturovandUmputun 8d42d0714f bump backend dependencies
Also, switch from fork github.com/umputun/go-flags back to original
github.com/jessevdk/go-flags.
2022-01-31 14:24:33 -06:00
Dmitry VerkhoturovandUmputun 3a9806a368 improve doc on backend run without docker 2022-01-30 19:05:57 -06:00
Dmitry Verkhoturov 31af19e456 make FindDeprecatedFlagsCollisions private method 2022-01-31 00:40:03 +03:00
Dmitry Verkhoturov 8689b11e7c log when deprecated and new args are set at the same time
For example, when notify.telegram.token and telegram.token
are both set but to different values, user might see
"access denied" error in log on attempt to send telegram
notification, thinking that notify.telegram.token value
is used, when in fact it is ignored and only telegram.token
is used.

New behavior is the same, ignoring the old param when new
one is set, but issuing the error log message which
explicitly tells the user about that.

Resolves #1218.
2022-01-31 00:40:03 +03:00
Dmitry VerkhoturovandUmputun ffa0d34ed1 improve backend and frontend development docs
Remove generic development documentation, make frontend
and backend pages more specific and self-sufficient,
as previously you had to read development and frontend
pages in order to understand how to properly develop
frontend.
2022-01-30 12:25:07 -06:00
Umputun dc9411e2b9 add work-in-progress note about tg auth docs 2022-01-30 04:09:45 -06:00
Dmitry VerkhoturovandUmputun bd42c75a84 improve telegram notifications documentation 2022-01-24 15:06:50 -06:00
Umputun 6af938051a drop drone legacy magic from artifact build 2022-01-23 16:25:24 -06:00
Umputun 33b96a4263 the same buildplatform only for node deps step 2022-01-23 16:04:14 -06:00
Umputun 2d033de224 build node things only once, on builplatform only 2022-01-23 16:02:46 -06:00
Umputun c5d56a89cd pass GITHUB_REF into docker build 2022-01-23 15:17:13 -06:00
Umputun e8bd5dfc6e add missing args 2022-01-23 15:12:19 -06:00
Umputun 077b2d297c switch to versions.sh instead of legacy git-rev.sh 2022-01-23 14:34:45 -06:00
Umputun 45c633776f skip legacy drone check 2022-01-23 14:06:26 -06:00
Dmitry VerkhoturovandUmputun ddba4059b0 add "invite your tg bot" note to documentation
Pointed out in #1221 originally.
2022-01-23 13:20:14 -06:00
Ivan UhalinandUmputun 434f0f5f3d update docs link 2022-01-20 02:23:53 -06:00
Paul MineevandUmputun aabee8312d update eleventy config 2022-01-18 13:09:30 -06:00
Paul MineevandUmputun 5d1f021667 update site lockfile 2022-01-18 12:59:17 -06:00
Umputun 8ef308e6a2 Revert "update site lockfile"
This reverts commit a5d1be80d7.
2022-01-18 12:59:03 -06:00
Paul MineevandUmputun a5d1be80d7 update site lockfile 2022-01-18 12:41:23 -06:00
Pavel MineevandUmputun dd06500573 bump frontend deps 2022-01-18 01:38:30 -06:00
Pavel MineevandUmputun 249dd5d2b2 move gatsby integration to manuals 2022-01-18 00:10:13 -06:00
Pavel MineevandUmputun ac610db7e3 bump site deps 2022-01-18 00:02:27 -06:00
a748e26166 Integrating Remark42 with Gatsby (documentation) (#1222)
* add sample Gatsby/React component with comments md

* fix typo

* remove semicolons

* add link to gatsby doc in nav.json

* fix typo

* make comment actually a comment in the return

* improve comment syntax

Co-authored-by: Ben <BenRoe@users.noreply.github.com>
2022-01-15 12:04:40 -06:00
Umputun 0521c7024c attempt to fix ci
another fix for ci

fix build tagged images
2022-01-12 12:16:37 -06:00
Ivan NedzveckijandUmputun c3b39b41ae telegram notifications format headers, resolve #1202 2022-01-12 11:40:00 -06:00
Paul MineevandUmputun f6772a7253 use double quotes 2021-12-24 04:17:39 -06:00
esvyridovandUmputun ab0c29cb0c Add container to a markdown table with overflow-x auto 2021-12-24 04:17:39 -06:00
esvyridovandUmputun f4ef0a7e78 Add data-testid to preloader and spinner, add test case for profile.spec 2021-12-23 17:44:45 -06:00
esvyridovandUmputun 78e2be8ea4 Show Preloader only on first request for comments in Profile sidebar 2021-12-23 17:44:45 -06:00
Pavel MineevandUmputun 6ec10c877b fix lint 2021-12-23 17:44:45 -06:00
Pavel MineevandUmputun 19121ff0b7 Review for load more comments in profile
- compose everything inside fetchComments
- put skip counter in ref and prevent unnecessary rerenders
- got rid of additional handlers for loading
- add spinner as loading indicator for loading of additional comments
2021-12-23 17:44:45 -06:00
esvyridovandUmputun 250f730d5e Remove unnecessary useMemo from Profile, add ru translation for user.load-more 2021-12-23 17:44:45 -06:00
esvyridovandUmputun 0bf5a91091 Add tests for updated getUserComments func 2021-12-23 17:44:45 -06:00
esvyridovandUmputun cd391a8b0e Add Load More button to profile sidebar 2021-12-23 17:44:45 -06:00
esvyridovandUmputun 53f02ab4cd Remove expandability of menu items from docs site 2021-12-23 12:59:16 -06:00
Dmitry VerkhoturovandUmputun 151913f371 style fixes to the documentation and CLI params description 2021-12-20 13:36:58 -06:00
Dmitry VerkhoturovandUmputun e341e25f0f improve notifications documentation (telegram, webhook) 2021-12-20 02:56:38 -06:00
Dmitry VerkhoturovandUmputun 4f672cdec5 fix formatting for telegram reply notifications 2021-12-20 00:07:15 -06:00
Dmitry VerkhoturovandUmputun 11c8bf6228 do not issue deprecation warning on notify.type by default
Default configuration for notify.type is "none",
and prior to this change it was issuing the
deprecation warning which was not an intended
behaviour.
2021-12-19 23:32:48 -06:00
Dmitry VerkhoturovandUmputun f43dfd57e0 clarify telegram channel ID param and documentation 2021-12-19 13:56:16 -06:00
Dmitry VerkhoturovandUmputun e1d629191e properly format email documentation 2021-12-19 13:54:49 -06:00
Umputun 27888eb331 switch base images to v1.8.0 2021-12-12 17:31:21 -06:00
Umputun fcb13e2016 update mod sum for examples 2021-12-07 13:12:34 -06:00
Umputun 5cb7a3f64b fix mod inconsistency 2021-12-07 13:08:23 -06:00
Umputun f621555ef4 update auth lib with /status support
potential fix for #1188 can use /auth/status
2021-12-07 13:03:12 -06:00
Bal Krishna JhaandUmputun c8361a3844 Add link of docker-compose file 2021-12-04 13:33:24 -06:00
Yuriy SynyaievandUmputun a1050472c4 feat: add cursor pointer for buttons 2021-11-29 03:00:57 -06:00
Dmitry VerkhoturovandUmputun 90e537358d update golangci-lint to 1.43.0, fix found issues 2021-11-23 15:00:40 -06:00
esvyridovandPaul Mineev 8e77acd908 Fix broken verified user icon 2021-11-22 14:12:21 -08:00
TimofeyandUmputun f03f99b5b4 update ru locale 2021-11-19 11:19:38 -06:00
adfslslddkdsandGitHub 2387518831 Add favicon to site (#1177)
* add favicon files

* do not add font description to production

* add correct image for favicon

* fix .dockerignore

by @adfslslddkds
2021-11-16 14:16:58 -06:00
witjemandUmputun 473e3328e1 updated comment for rest.SendErrorJSON 2021-11-12 03:32:14 -06:00
KsiniaandUmputun 832f4fd858 Fix typos in frontend code 2021-11-09 02:05:41 -06:00
Dmitry VerkhoturovandUmputun be3643ed10 use single loop for telegram auth and notify 2021-11-09 02:04:51 -06:00
Dmitry VerkhoturovandUmputun c751fbaf37 bump project dependencies, go-auth 2021-11-08 11:41:07 -06:00
Dmitry VerkhoturovandUmputun 90ec2ff907 move more parts of Readme to the docs site 2021-11-07 11:58:08 -06:00
Dmitry VerkhoturovandUmputun 19a4eada25 move privacy section of readme to the docs site 2021-11-07 11:53:28 -06:00
Dmitry VerkhoturovandUmputun c027dcd765 enable telegram notify trough writing bot a message
Previously it was done through writing bot first,
clicking a button, copying the token, and pasting
it into the web interface.

The new flow is way simpler: click the link
to write bot a message, then click the "Check"
button in the web UI and you got notifications
enabled.
2021-11-07 11:51:28 -06:00
Dmitry VerkhoturovandUmputun e1a2374a2d clarify telegram notification flow 2021-11-04 15:42:04 -05:00
Dmitry VerkhoturovandUmputun 6449b7d92b improve telegram notifications
These changes are designed to ease the transition into
the simplified telegram notifications verification model.
2021-11-01 14:35:37 -05:00
Dmitry VerkhoturovandUmputun c852ea4834 bump go modules 2021-10-29 11:48:46 -05:00
Umputun 8818faf189 add darwin arm64 bin release 2021-10-28 23:30:45 -05:00
Dmitry VerkhoturovandUmputun 4d03c20be6 fix non-HTTPS site link on the privacy page 2021-10-25 17:29:55 -05:00
Dmitry VerkhoturovandUmputun b3d28f97ae fix links in docs, move frontend readme to site 2021-10-25 17:21:24 -05:00
Dmitry VerkhoturovandUmputun 8fc9141f19 extract TelegramBotInfo structure 2021-10-25 16:47:15 -05:00
Dmitry VerkhoturovandUmputun ca7cbedea4 regenerate mocks and use require.NoError in place of Nil 2021-10-25 16:47:15 -05:00
Dmitry VerkhoturovandUmputun 498073c509 send telegram messages in HTML mode
That resolves problem with inability
to properly render message text in the
resulting telegram message due to markdown
escaping trickiness.
2021-10-24 14:05:06 -05:00
Dmitry VerkhoturovandUmputun f817fd38ed improve telegram messages escaping
Due to the wrong order of `html.UnescapeString`
applying, messages sometimes ended up crippled.

Due to `parse_mode=Markdown` set in Telegram
send message call, `ParseMode` in message
was ignored.
2021-10-24 14:05:06 -05:00
esvyridovandUmputun ad51e087b1 Add EOF to counter.module.css 2021-10-23 11:39:33 -05:00
esvyridovandUmputun ad5aed9787 Rename commentsCounts variable to commentsAmount, update condition for showing amount of comments 2021-10-23 11:39:33 -05:00
esvyridovandUmputun 27ee16180b Add user.my-comments and user.comments keys to it translations 2021-10-23 11:39:33 -05:00
esvyridovandUmputun f104c3822f Add tests for comments counter in profile sidebar 2021-10-23 11:39:33 -05:00
esvyridovandUmputun eb941fc095 Update profile sidebar title, add comments counter next to the title 2021-10-23 11:39:33 -05:00
Dmitry VerkhoturovandUmputun aa34dc8384 move non-technical part of the readme to site 2021-10-23 11:37:23 -05:00
Dmitry VerkhoturovandUmputun c222b8a2ba cut parameters list from readme to site 2021-10-22 01:21:33 -05:00
Dmitry VerkhoturovandUmputun 032f88c61e move technical part of readme to docs site 2021-10-21 13:06:40 -05:00
MiloandUmputun 3d06a04acd Updated documentation with new Italian translations 2021-10-19 11:50:22 -05:00
MiloandUmputun 2889cd0b40 Add italian translations 2021-10-19 11:50:22 -05:00
Umputun edb45a61fc fix example mod 2021-10-18 23:53:08 -05:00
Umputun bec8079af9 update bluemonday deps 2021-10-18 23:45:22 -05:00
Dmitry VerkhoturovandUmputun 4793a31075 move previewCommentCtrl to private REST struct 2021-10-16 11:04:03 -05:00
Dmitry VerkhoturovandUmputun d0d4f06bad replace deprecated CLI options with current ones 2021-10-16 10:58:35 -05:00
dependabot[bot]andUmputun 7b1de77dc2 Bump tmpl from 1.0.4 to 1.0.5 in /frontend
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-10-15 13:38:21 -05:00
UmputunandGitHub 73f7e203b6 Merge pull request #1144
patreon auth
2021-10-15 13:36:40 -05:00
romanilchyshyn df0d4d27fa patreon auth 2021-10-13 00:30:14 +03:00
Umputun 0c069c1e8f remove hard breaks from privacy policy 2021-10-10 12:49:02 -05:00
Pavel MineevandUmputun 24f989109a restore pricavy policy, add footer 2021-10-10 12:32:42 -05:00
Umputun 106fb1ff38 update security versions 2021-10-09 13:50:50 -05:00
Umputun 6d81eaefd1 revert as dup 2021-10-09 13:10:56 -05:00
Umputun 509d29dea6 add api section to docs 2021-10-09 13:05:03 -05:00
romanilchyshynandUmputun e341227cae import from commento engine 2021-10-06 13:51:31 -05:00
Dmitry VerkhoturovandUmputun 5abeab4008 stop Ticker after use to prevent the memory leak
https://github.com/golang/go/wiki/CodeReviewConcurrency#ticker-stop
2021-10-03 16:09:30 -06:00
Umputun 7f575c4dd7 add multiarch make target 2021-09-24 16:17:47 -05:00
dependabot[bot]andUmputun ab3a6f7fe6 Bump nth-check from 2.0.0 to 2.0.1 in /site
Bumps [nth-check](https://github.com/fb55/nth-check) from 2.0.0 to 2.0.1.
- [Release notes](https://github.com/fb55/nth-check/releases)
- [Commits](https://github.com/fb55/nth-check/compare/v2.0.0...v2.0.1)

---
updated-dependencies:
- dependency-name: nth-check
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-21 10:40:00 -05:00
Umputun 198759ca1b rename jobs to avoid confusions 2021-09-21 03:52:31 -05:00
Pavel MineevandUmputun 337b4d633e update site deps to the last versions 2021-09-21 03:48:54 -05:00
dependabot[bot]andUmputun 65479f8207 Bump prismjs from 1.24.0 to 1.25.0 in /site
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.24.0 to 1.25.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.24.0...v1.25.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-21 02:54:19 -05:00
Umputun 8a640b98f6 fix ci formatting 2021-09-21 01:44:41 -05:00
Umputun 9fbf095225 add multi-arch docker build 2021-09-21 01:43:21 -05:00
Dmitry VerkhoturovandUmputun 3f0ede560c bump go modules in the project 2021-09-08 11:34:48 -05:00
Pavel MineevandUmputun 5c59e87f94 bump vulnerable frontend dependencies 2021-09-08 11:32:39 -05:00
Dmitry VerkhoturovandUmputun 2443b9d0a2 prevent data race within TestService_UserReplies 2021-09-04 15:22:02 -05:00
Dmitry VerkhoturovandUmputun fa6c0e0d5e don't use "t" inside assert.Eventually 2021-09-04 13:00:25 -05:00
Pavel MineevandUmputun 987a066a80 add more space between error message and retry button 2021-09-04 12:45:07 -05:00
esvyridovandUmputun c0dc64f0a0 Update user profile UI: move loading and error to center 2021-09-04 12:45:07 -05:00
esvyridovandUmputun 3d3162de41 Update UI for user comments sidebar 2021-09-04 12:45:07 -05:00
Dmitry VerkhoturovandUmputun bb3c86281c make TestService_UserReplies more robust
It flaps (see #380), and with this change, it will
have more time to get the expected output in
the flaky GitHub Actions environment.
2021-09-04 12:44:42 -05:00
Umputun 91441d1160 add controversy and imported to untrusted list 2021-09-01 14:21:52 -05:00
Umputun 72bdf1b176 fix quoting issue 2021-09-01 14:18:18 -05:00
Umputun e90dae2b94 sanitize Title on find level as well 2021-09-01 14:14:12 -05:00
Umputun a7b44eee1a sanitize PostTitle 2021-09-01 13:56:06 -05:00
Umputun 8754add874 add skip to user's comment rest request #1085 2021-08-29 12:45:35 -05:00
bakurinandUmputun be46e849a4 Webhook destination for notifications 2021-08-28 12:50:36 -05:00
Sergii GatezhandUmputun 9f6c766919 Apply feedback 2021-08-20 11:22:31 -05:00
6f3fa4084c Update frontend/app/locales/ua.json
Co-authored-by: Eugene <ievgenteslia@gmail.com>
2021-08-20 11:22:31 -05:00
de0656d6a9 Update frontend/app/locales/ua.json
Co-authored-by: Eugene <ievgenteslia@gmail.com>
2021-08-20 11:22:31 -05:00
4556133371 Update frontend/app/locales/ua.json
Co-authored-by: Eugene <ievgenteslia@gmail.com>
2021-08-20 11:22:31 -05:00
f92b49d3d0 Update frontend/app/locales/ua.json
Co-authored-by: Eugene <ievgenteslia@gmail.com>
2021-08-20 11:22:31 -05:00
Sergii GatezhandUmputun 02f671b891 Apply feedback 2021-08-20 11:22:31 -05:00
5c083e7e0a Update frontend/app/locales/ua.json
Co-authored-by: Eugene <ievgenteslia@gmail.com>
2021-08-20 11:22:31 -05:00
Sergii GatezhandUmputun 61230646b3 Update Ukrainian locale 2021-08-20 11:22:31 -05:00
Pavel MineevandUmputun 5718a699cf remove unused const, move single used var to chunk 2021-08-19 12:55:56 -05:00
Dmitry VerkhoturovandUmputun fd5df39fe2 bump backend auth module to fix telegram auth 2021-08-17 16:33:53 -05:00
RikoDEVandUmputun f0ae4b6de8 Update Polish localization 2021-08-15 14:24:42 -05:00
UmputunandGitHub de4ab97c9a Merge pull request #1101 from umputun/paskal/email_doc
clarify user and admin email instructions
2021-08-14 14:19:33 -05:00
Dmitry VerkhoturovandGitHub 58409ae05c clarify user and admin email instructions 2021-08-14 12:41:55 +02:00
Umputun 193b107590 add remote site deployment from master 2021-08-11 01:45:39 -05:00
UmputunandGitHub 059d6f0455 Merge pull request #1098 from umputun/dependabot/npm_and_yarn/frontend/url-parse-1.5.3 2021-08-10 18:53:16 -05:00
dependabot[bot]andGitHub 6f70823df8 Bump url-parse from 1.5.1 to 1.5.3 in /frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.1 to 1.5.3.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.1...1.5.3)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-10 23:52:56 +00:00
UmputunandGitHub 7945240436 Merge pull request #1097 from umputun/dependabot/npm_and_yarn/frontend/path-parse-1.0.7 2021-08-10 18:51:28 -05:00
dependabot[bot]andGitHub 29ff5aee94 Bump path-parse from 1.0.6 to 1.0.7 in /frontend
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-10 23:50:46 +00:00
UmputunandGitHub f5ffb208b1 Merge pull request #1096 from umputun/site-fb-auth-manual
docs: edit facebook oauth doc
2021-08-10 02:44:26 -05:00
Pavel Mineev f2c13d3126 edit facebook oauth manual 2021-08-10 10:36:21 +03:00
Umputun 3f7fbb4b31 rename site build 2021-08-09 19:45:43 -05:00
Umputun b769fe45bd change nginx-le to reproxy 2021-08-09 19:43:13 -05:00
UmputunandGitHub ff9359aa14 Merge pull request #1086 from umputun/site-link-to-releases
set releases link on version in header
2021-08-09 16:25:03 -05:00
Pavel MineevandUmputun 184ac7efb6 fix title on icon button 2021-08-09 11:10:19 -05:00
Pavel MineevandUmputun 79386a8b14 move docs about third party soft in manual section 2021-08-09 11:09:33 -05:00
Pavel MineevandUmputun 712728f554 update license year 2021-08-09 11:08:42 -05:00
Pavel MineevandGitHub 67ab44dd82 Merge branch 'master' into site-link-to-releases 2021-08-09 10:39:28 +03:00
Pavel MineevandUmputun 5668614945 fix link to demo page in header 2021-08-09 02:04:46 -05:00
Pavel Mineev 57af9e7a66 set releases link on version in header 2021-08-09 00:49:30 +03:00
Pavel MineevandUmputun 7f0a01a6ca fix demo page locator 2021-08-08 15:50:23 -05:00
Umputun 878d5c6cf3 change CMD to RUN in the build step 2021-08-08 15:21:21 -05:00
Umputun cc686557ef typo 2021-08-08 15:16:12 -05:00
Umputun a752f14ecb no-cache for site ci 2021-08-08 15:14:47 -05:00
Umputun 7b0da93f64 add ls to ci build step 2021-08-08 15:04:32 -05:00
Umputun 8729ff81cd missing working directory 2021-08-08 14:59:46 -05:00
Umputun f7d099d8fc typo 2021-08-08 14:48:54 -05:00
Umputun 94f6ff0aef add ci build for site 2021-08-08 14:48:13 -05:00
Pavel MineevandUmputun cac1eb2601 remove unused code 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun 3216641cbc unify icons 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun 3314ed30a8 add sort-picker 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun e5e9d5a10e format 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun 76244fca88 remove unused vars 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun a733733e69 update sort-picker 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun b47174af4a add tests for sort picker and update select tests 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun 718ad691c0 add tests for select 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun 7317d46699 update locales 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun a04204b3d5 move sort picker below comment form 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun be41016266 fix: open own profile from comment 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun c6fcb06086 fix typo 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun 484e547c79 fix spinner 2021-08-08 14:25:10 -05:00
Pavel MineevandUmputun 3a20bfcbb2 profile popup 2021-08-08 14:25:10 -05:00
Umputun 628dd898b6 change site docker build to proper two-stage with repoxy to serve content 2021-08-08 13:55:10 -05:00
Dmitry VerkhoturovandUmputun 9df8de511a Run telegram auth goroutine
Fix for https://github.com/go-pkgz/auth/issues/90
2021-08-06 16:56:07 -05:00
Dmitry VerkhoturovandUmputun 7b28cb9fa9 fix typo in oauth 2021-08-05 18:14:50 -05:00
Pavel MineevandUmputun a45c1d1994 update docs nav definition 2021-08-05 12:57:43 -05:00
Pavel MineevandUmputun ad1f95dc74 format md files
- use doublequote in yaml
- right indentation in yaml
- format missformatted places
2021-08-05 12:22:49 -05:00
Pavel MineevandUmputun c5e3ea898d fix images 2021-08-05 03:23:51 -05:00
Pavel MineevandUmputun 8d33265487 remove quote style because it isn't supported 2021-08-03 03:51:22 -05:00
Pavel MineevandUmputun 36840ed240 double quotes in yml 2021-08-03 03:22:27 -05:00
Pavel MineevandUmputun b27a09fb3f Disable injection of last-comments.css in dev
...because it handled by webpack
2021-08-01 16:08:19 -05:00
Pavel MineevandUmputun 2285bae782 add eof 2021-08-01 12:26:40 -05:00
Pavel MineevandUmputun 0352d047fa use 2 space indentation for yml 2021-08-01 12:26:40 -05:00
Pavel MineevandUmputun d7936a6a29 optimize css 2021-07-29 10:35:13 -05:00
Dmitry VerkhoturovandUmputun 0efc04e5cd fix deprecated flag, improve tests 2021-07-27 11:45:42 -05:00
Umputun 4c2f2aa097 change go.mod version to 1.16, change ci to 1.16 2021-07-27 04:25:27 -05:00
Pavel MineevandUmputun 4d1d32016e fix typo 2021-07-27 04:22:40 -05:00
Pavel MineevandUmputun deb7f7c209 use spaces for indentation in markdown
- adds global editorconfig (supports in intellij by default and with plugin in vscode)
- fixes prettier config
2021-07-27 04:22:40 -05:00
Pavel MineevandUmputun 6b9770b8b6 fix placeholder 2021-07-27 04:22:40 -05:00
Pavel MineevandUmputun 07b6454b79 add docs from readme on the site 2021-07-27 04:22:40 -05:00
Umputun 5abdaaf793 clarify image proxy description 2021-07-26 21:25:22 -05:00
Umputun b66c94e002 migrate jwt lib to maintained fork and updated (the same way) go-pkgz/auth 2021-07-26 21:06:12 -05:00
Pavel MoiseenkoandUmputun 2d32765453 Update information in index.md
Synchronize the project description with the description from the repository.
2021-07-26 11:01:55 -05:00
Dmitry VerkhoturovandUmputun 2c8556ff2f consistent formatting for markdown files 2021-07-25 13:28:08 -05:00
Dmitry VerkhoturovandUmputun ea644a1a31 add telegram auth backend support 2021-07-25 13:16:50 -05:00
Pavel MoiseenkoandUmputun 50b3776d2a Improve README
* Update links
* Update table of contents
* Improve the design
* Improve formatting
* Fix abbreviations and proper names
* Fix tables
* Fix indentation in code snippets
* Fix typos
2021-07-25 13:08:34 -05:00
Dmitry VerkhoturovandUmputun f093d6a07a don't run CI pipelines on markdown file changes 2021-07-25 13:06:30 -05:00
Dmitry VerkhoturovandUmputun c15a280181 don't run CI pipelines on frontend Readme file change 2021-07-25 12:58:48 -05:00
Umputun ad54a9450b fix parent tg formatting 2021-07-04 00:30:11 -05:00
Umputun b8050f817c quote parrent comment in tg notif 2021-07-04 00:03:23 -05:00
Umputun 70245504f5 restore link to original in tg notif 2021-07-03 23:52:20 -05:00
Umputun 05a85b6bfd reformat tg message to make it readable again 2021-07-03 23:48:00 -05:00
Dmitry VerkhoturovandUmputun 83ae758573 address review commends 2021-07-03 14:57:09 -05:00
Dmitry VerkhoturovandUmputun 200733ed03 add user telegram notifications 2021-07-03 14:57:09 -05:00
dependabot[bot]andUmputun 7f081e1d2c Bump prismjs from 1.23.0 to 1.24.0 in /site
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.23.0 to 1.24.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.23.0...v1.24.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-28 16:05:01 -05:00
Dmitry VerkhoturovandUmputun 936ccd825f Move existing documentation to the new site 2021-06-27 14:53:20 -05:00
Pavel MineevandUmputun 68aba33765 fix typo 2021-06-16 01:27:10 -05:00
Pavel MineevandUmputun 5aa24f95fc fix more comments button 2021-06-16 01:17:20 -05:00
Dmitry VerkhoturovandUmputun 2b15e9291f fix typos 2021-06-15 02:02:48 -05:00
Namkhai BandUmputun 98bfc7f5f5 Don't set X-XSRF-TOKEN when the user isn't logged in
An HTTP header cannot be empty, and although some webservers allow this
(nginx, Apache), others answer 400 Bad Request (lighttpd), preventing
the widget from loading.
2021-06-15 02:01:57 -05:00
Dmitry VerkhoturovandUmputun 1847184960 clarify telegram notifications code and text 2021-06-13 19:07:56 -05:00
Dmitry VerkhoturovandUmputun ef6979e7d6 improve email notifications text 2021-06-13 19:07:56 -05:00
Dmitry VerkhoturovandUmputun b96f29aa26 add Intellij http-client.env.json to gitignore 2021-06-13 19:07:56 -05:00
dfcf728e6f Site (#1049)
by @akellbl4 

* create infrastructure for site

* wip

* fix docker build and add readme

* add docker-compose as a build and a run method

* rename compose file yaml -> yml

* add `src` as volume for watching changes

* update configs

* update README

* add padding at the end of the pages

* move demo settings in config

* fetch latest release from github

* update docs navigation

- add sections
- redirect from root of the section to first doc
- nice styles for navigation
- add brand colors

* cache github data from first load

* add redirects and fix link to docs

* fix docs nav styles

* add installation page placeholder

* fix demo

* add 404

* add dark theme, add theme switcher, remove unused files

* fix dark theme on main page

* fix dark theme background

* fix node version

* change installation docs

* add note block

* minor fixes

* add code highlighting styles

* fixes

* fixes

* mobile navigation, fix code highlighting colors

* fix dev server

* fix fetching error

* fix path to edit

Co-authored-by: Pavel Mineev <pavel@mineev.me>
Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2021-06-13 18:44:49 -05:00
StanislasandUmputun 5ce89c6c29 docs(import): clarify import path for wordpress 2021-06-13 17:40:01 -05:00
Dmitry VerkhoturovandUmputun de6e541604 fix telegram token server setting 2021-06-13 15:47:52 -05:00
Dmitry VerkhoturovandUmputun fd4c6ceb18 add UserDetailTelegram support 2021-06-13 13:18:33 -05:00
Dmitry VerkhoturovandUmputun d6167980f4 remove ability to set telegram API, clarify params 2021-06-13 01:49:15 -05:00
Dmitry VerkhoturovandUmputun ea15b28bf6 clarification of notify comments and code 2021-06-12 12:59:26 -05:00
Pavel MineevandUmputun 4f0002abdb chage iframe size if any element is changed 2021-06-09 13:09:57 -05:00
dependabot[bot]andUmputun be60c7a481 Bump trim-newlines from 3.0.0 to 3.0.1 in /frontend
Bumps [trim-newlines](https://github.com/sindresorhus/trim-newlines) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/sindresorhus/trim-newlines/releases)
- [Commits](https://github.com/sindresorhus/trim-newlines/commits)

---
updated-dependencies:
- dependency-name: trim-newlines
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-09 13:03:36 -05:00
dependabot[bot]andUmputun c22d371157 Bump normalize-url from 4.5.0 to 4.5.1 in /frontend
Bumps [normalize-url](https://github.com/sindresorhus/normalize-url) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/sindresorhus/normalize-url/releases)
- [Commits](https://github.com/sindresorhus/normalize-url/commits)

---
updated-dependencies:
- dependency-name: normalize-url
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-09 11:21:29 -05:00
dependabot[bot]andUmputun 91deae576b Bump ws from 6.2.1 to 6.2.2 in /frontend
Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/commits)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-04 21:10:23 -05:00
Pavel MineevandUmputun 3068dfe637 post messages from iframe
- fix user info opening
- remove iframe height checks by interval
- update height by mutation observer events
- rename events
2021-06-03 00:56:16 -05:00
Dmitry VerkhoturovandUmputun c0b392ad4c separate user and admin notifications
The current state is a mess of user and admin
notifications, which will become worse after
implementing the new user notification methods
like a telegram.

This change makes things simpler
for the remark42 users.
2021-06-03 00:30:53 -05:00
Pavel MineevandUmputun 97934e23f9 fix tests 2021-05-30 12:50:37 -05:00
Pavel MineevandUmputun 79eab8ccde fix copy message with styles 2021-05-30 12:50:37 -05:00
Dmitry VerkhoturovandUmputun c6e2c38e34 fix email templates path in tests
Before:
failed to make notify service,
failed to create email notification destination:
can't set templates:
can't read message template:
open email_reply.html.tmpl:
no such file or directory

After:
make notify, types=[email]
create notifier service, queue size=100, destinations=1
2021-05-27 12:23:12 -05:00
Vladimir RusinovandUmputun 93a75d698e Add example of Helm-less Kubernetes setup 2021-05-27 11:36:52 -05:00
dependabot[bot]andUmputun 7b580cec15 Bump dns-packet from 1.3.1 to 1.3.4 in /frontend
Bumps [dns-packet](https://github.com/mafintosh/dns-packet) from 1.3.1 to 1.3.4.
- [Release notes](https://github.com/mafintosh/dns-packet/releases)
- [Changelog](https://github.com/mafintosh/dns-packet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mafintosh/dns-packet/compare/v1.3.1...v1.3.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-27 11:08:24 -05:00
Dmitry VerkhoturovandUmputun 1cff4eb847 clarify deprecation messages
Turned out we don't really want to break
users configurations, so deprecated values
stay with us at least before 2.0 is released.
2021-05-25 14:42:29 -05:00
Dmitry VerkhoturovandUmputun b13fa228b6 start using the provided timeout in the telegram notify 2021-05-25 13:29:19 -05:00
dependabot[bot]andUmputun 8d8c6ee93d Bump browserslist from 4.16.0 to 4.16.6 in /frontend
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.16.0 to 4.16.6.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.16.0...4.16.6)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-24 22:45:46 -05:00
Dmitry VerkhoturovandUmputun 9fa23cc537 reset image cleanup TTL on Submit
Also:

- make commitTTL equal to EditDuration,
  so that image is committed to permanent
  storage after comment can no longer be edited
- move cleanupTTL to Cleanup function,
  as it's not used elsewhere in the code
- add variables to some tests sleeps, so that
  instead of being magic numbers they would
  rely on timers of structures they suppose
  to wait for
2021-05-24 17:33:59 -05:00
Dmitry VerkhoturovandUmputun 86b2648d66 reset image cleanup timer on comment preview 2021-05-24 17:33:59 -05:00
Dmitry VerkhoturovandUmputun 0e550e83fa add method to renew image cleanup timer 2021-05-24 17:33:59 -05:00
Dmitry VerkhoturovandUmputun 82387729a4 make docker init script chown command verbose 2021-05-24 17:16:53 -05:00
Dmitry VerkhoturovandUmputun 4f2db3cdf6 cover all auth providers with tests, clean up env 2021-05-24 17:15:58 -05:00
Dmitry VerkhoturovandUmputun 6a54ed86c7 move telegram token and timeout to a separate CLI section
This simplifies token and timeout reuse for
the notify module (used now) and for
the auth module later (not yet in the code).
SMTP credentials are already set up that way.
2021-05-24 12:15:03 -05:00
Umputun b856b6d3af add reproxy setup manual 2021-05-20 18:47:45 -05:00
Dmitry VerkhoturovandUmputun fcaf568fcc update wordpress instructions, fix #871 2021-05-20 13:30:09 -05:00
Umputun 3a8750bd58 adopt deployment destination 2021-05-20 10:45:59 -05:00
Yuriy KarpovandUmputun 6f10a5a4a3 fix mobile navigation to comment for hidden comments
issues-82
2021-05-19 03:59:25 -05:00
Dmitry VerkhoturovandUmputun 29ac37c8ea copy init script earlier than we change /srv owner
That would prevent entrypoint script
(https://github.com/umputun/baseimage/blob/master/base.alpine/files/init.sh)
failure on init.sh while running as "app" and not "root"
2021-05-18 13:56:43 -05:00
Pavel MineevandUmputun 63ae392927 fix oauth text color 2021-05-18 13:29:33 -05:00
Dmitry VerkhoturovandUmputun 4d011aa5f3 remove error return from ExtractPictures
That function returns an error in a never
expected condition, and that error would be
logged message on the caller side:
none of the callers handles it.

That change hides that error from the caller
so that function would have a signature that
better fit what it does and how it behaves.
2021-05-17 21:31:56 -05:00
Pavel MineevandUmputun 75196d185d avatar fixes after review 2021-05-17 21:29:46 -05:00
Pavel MineevandUmputun 93b29e934b renew avatar 2021-05-17 15:14:35 -05:00
Dmitry VerkhoturovandUmputun f9eb39db03 make commitTTL equal to EditDuration
So that image is committed to permanent
storage after comment can no longer be edited.

Also, move cleanupTTL to Cleanup function,
as it's not used elsewhere in the code.
2021-05-17 01:55:05 -05:00
Dmitry VerkhoturovandUmputun df547cc815 less magic consonants in tests 2021-05-17 01:55:05 -05:00
Dmitry VerkhoturovandUmputun ef1dd8162b make consistent returns in bolt_store 2021-05-17 01:55:05 -05:00
Dmitry VerkhoturovandUmputun 717c4aa638 rename variables according to golangci-lint rec. 2021-05-17 01:55:05 -05:00
cbdf5f1e47 Fix text
Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2021-05-16 16:19:44 -05:00
Pavel MineevandUmputun ba5c74a069 Add a new error message and correct another one 2021-05-16 16:19:44 -05:00
Dmitry VerkhoturovandUmputun fe716b0a71 validate image existence before post or preview 2021-05-16 13:50:09 -05:00
ElephmoonandUmputun 992b843cf5 postMessage without serialization 2021-05-16 13:41:50 -05:00
Dmitry VerkhoturovandUmputun d1ef3ff247 fix HTTP response body close in proxy/image.downloadImage 2021-05-13 18:06:20 -05:00
Dmitry VerkhoturovandUmputun 63220f330b close response body, add lint to check it 2021-05-13 17:58:59 -05:00
Umputun 57fc382956 add ability to select listening address #1000 2021-05-13 14:46:52 -05:00
Dmitry VerkhoturovandUmputun c318e2068e bump baseimage:app to be consistent with :buildgo 2021-05-13 11:22:16 -05:00
Umputun 470956bbc8 no-cache and pull to artifact build make #994 2021-05-10 19:22:56 -05:00
UmputunandGitHub 52fbac904e Admin edit (#997)
* allow admin edits without restrictions

* lint: err reassignment

* lint: suppress false positive

* add admin-edit to readme

* expose admin_edit to config controller
2021-05-10 11:36:02 -05:00
dependabot[bot]andUmputun 6d4be02dab Bump hosted-git-info from 2.8.8 to 2.8.9 in /frontend
Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-10 02:59:57 -05:00
Dmitry VerkhoturovandUmputun 19ba43b843 dockerfile: remove dead code, bump buildgo 2021-05-07 23:27:58 -05:00
Umputun c055749dae revendor to auth:master to provide fix for 404 avatar 2021-05-07 20:00:24 -05:00
Dmitry VerkhoturovandUmputun 4a2ae04571 replace deprecated golangci-lint check, fix goleak reports 2021-05-07 16:09:28 -05:00
Umputun eb7b34b6b2 add related projects section 2021-05-06 19:27:55 -05:00
Umputun 5bdd1a5c72 lint: remove unneeded lambda 2021-05-06 19:14:07 -05:00
UmputunandGitHub 9838aaac70 Disqus fix (#989) 2021-05-06 18:03:15 -05:00
Pavel MineevandUmputun 048712bcee remove csso 2021-05-06 16:10:25 -05:00
Pavel MineevandUmputun cb40bae89b minify css bundle 2021-05-06 16:10:25 -05:00
Pavel MineevandUmputun d8058c6576 use named imports 2021-05-06 15:55:46 -05:00
Pavel MineevandUmputun 123cd53c09 fix linter 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun 317d932b1f latest versions 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun 58d8fe0f1c fix linter errors 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun 3c6df9e17e bump 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun b3beca1559 make classnames short 2021-05-06 15:16:39 -05:00
Pavel MineevandUmputun 1ee571a7f5 remove @types/classnames 2021-05-06 15:11:36 -05:00
Pavel MineevandUmputun 9f98b46351 replace classnames to faster and smaller package 2021-05-06 15:11:36 -05:00
Pavel MineevandUmputun 928be7d688 add type module while load modern js bundle 2021-05-06 15:08:20 -05:00
Pavel MineevandUmputun 5782495b8e add new error to locales 2021-05-06 15:08:03 -05:00
Pavel MineevandUmputun 3ad7ba2c09 make form available after failed commit sending 2021-05-06 15:08:03 -05:00
Yuriy KarpovandUmputun 4e0a4e52bd add the ability to configure the 'simple_view' parameter from the client,
the parameter is not required, but if set, it will overwrite the one that came from the backend
issues-916
2021-05-06 11:08:15 -05:00
Dmitry VerkhoturovandUmputun e841bc27fb clarify --secret usage based on #861 2021-05-05 02:39:32 -05:00
Umputun d361564815 fix user view in find for deleted comments #972 2021-05-05 01:15:03 -05:00
Umputun d3bd966df8 switch version detection to /script/version.sh in artifact release 2021-05-04 18:28:49 -05:00
4098 changed files with 478484 additions and 321992 deletions
+16 -3
View File
@@ -1,15 +1,26 @@
/logs/
/target/
/var/
/frontend/node_modules/
/frontend/public/
/.vscode/
/.idea/
/bin/
/.git/
# frontend files not needed in docker image
/frontend/node_modules/
/frontend/apps/remark42/node_modules/
/frontend/apps/remark42/public/
# source files
docker-compose.yml
compose-dev-backend.yml
compose-dev-frontend.yml
compose-private-backend.yml
compose-private-frontend.yml
compose-e2e-test.yml
compose-private.yml
rest-client.env.json
Makefile
# generated files
*.cov
@@ -21,4 +32,6 @@ debug.test
*.test
remark42
/backend/var/
compose-private-backend.yml
# go e2e suite, never built into the image
/e2e/
-86
View File
@@ -1,86 +0,0 @@
kind: pipeline
name: default
type: docker
steps:
- name: build server
image: umputun/baseimage:buildgo-latest
commands:
- cd backend/app
- go build -mod=vendor
- echo "build completed"
- name: docker master
image: plugins/docker
settings:
repo: umputun/remark42
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
- DRONE_BRANCH=${DRONE_BRANCH}
tags:
- ${DRONE_COMMIT_BRANCH/\//-}
when:
branch: [master]
event: push
- name: docker tag
image: plugins/docker
settings:
repo: umputun/remark42
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
tags:
- ${DRONE_TAG}
- latest
when:
event: tag
- name: artifacts tag
image: plugins/docker
settings:
dockerfile: Dockerfile.artifacts
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
- GITHUB_TOKEN=${GITHUB_TOKEN}
when:
event: tag
- name: deploy
image: docker.umputun.com/system/deploy-ci:master
commands:
- ssh umputun@remark42.com "cd /srv/remark && docker-compose pull"
- ssh umputun@remark42.com "cd /srv/remark && docker-compose up -d"
- ssh umputun@remark42.com "cd /srv/remark-site && git pull && git submodule update --recursive --remote"
- ssh umputun@remark42.com "cd /srv/remark-site && docker-compose build && docker-compose up"
when:
branch: master
event: push
- name: notify
image: drillster/drone-email
settings:
host: smtp.mailgun.org
username:
from_secret: email_username
password:
from_secret: email_password
from: drone@mg.umputun.dev
recipients: [ sys@umputun.dev ]
when:
status: [ changed, failure ]
+13
View File
@@ -0,0 +1,13 @@
root = true
[*]
indent_style = tab
insert_final_newline = true
[*.md]
indent_style = space
trim_trailing_whitespace = false
[*.{yml,json}]
indent_size = 2
indent_style = space
+2 -1
View File
@@ -2,4 +2,5 @@
# Unless a later match takes precedence, @umputun will be requested for
# review when someone opens a pull request.
* @umputun
* @umputun
frontend/* @umputun @akellbl4 @Mavrin
+66
View File
@@ -0,0 +1,66 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
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:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
"GitHub Actions updates":
patterns:
- "*"
- package-ecosystem: "gomod"
directory: "/backend"
schedule:
interval: "monthly"
groups:
"Go modules updates":
dependency-type: "production"
- package-ecosystem: "gomod"
directory: "/e2e"
schedule:
interval: "monthly"
groups:
"Go modules updates":
dependency-type: "production"
- package-ecosystem: "npm"
directory: "/frontend"
open-pull-requests-limit: 0
ignore:
- dependency-name: "*"
schedule:
interval: "monthly"
groups:
"NPM modules updates":
dependency-type: "production"
"NPM modules updates for tests":
dependency-type: "development"
- package-ecosystem: "npm"
directory: "/frontend/apps/remark42"
open-pull-requests-limit: 0
ignore:
- dependency-name: "*"
schedule:
interval: "monthly"
groups:
"NPM modules updates":
dependency-type: "production"
"NPM modules updates for tests":
dependency-type: "development"
- package-ecosystem: "docker"
directory: "/site"
schedule:
interval: "monthly"
groups:
"Site image updates":
patterns:
- "*"
+117
View File
@@ -0,0 +1,117 @@
name: backend
on:
push:
branches:
tags:
paths:
- ".github/workflows/ci-backend.yml"
- "backend/**"
- "Dockerfile"
- "docker-init.sh"
- ".dockerignore"
- "!backend/scripts/**"
- "!**.md"
pull_request:
paths:
- ".github/workflows/ci-backend.yml"
- "backend/**"
- "Dockerfile"
- "docker-init.sh"
- ".dockerignore"
- "!backend/scripts/**"
- "!**.md"
jobs:
test:
name: Test & Coverage
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: debug if needed
run: if [[ "$DEBUG" == "true" ]]; then env; fi
env:
DEBUG: ${{secrets.DEBUG}}
- name: install go
uses: actions/setup-go@v7
with:
go-version: "1.25"
check-latest: true
cache-dependency-path: backend
- name: test and build backend
run: |
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
go build -race ./...
working-directory: backend/app
env:
TZ: "America/Chicago"
- name: test examples
run: |
go test -race ./...
go build -race ./...
working-directory: backend/_example/memory_store
env:
TZ: "America/Chicago"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: "v2.13.1"
working-directory: backend/app
- name: golangci-lint on example directory
uses: golangci/golangci-lint-action@v9
with:
version: "v2.13.1"
args: --config ../../.golangci.yml
working-directory: backend/_example/memory_store
- name: submit coverage
run: |
go install github.com/mattn/goveralls@latest
goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
working-directory: backend
env:
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"
+52 -20
View File
@@ -1,31 +1,63 @@
name: build
on:
push:
branches:
tags:
paths:
- '.github/workflows/ci-build.yml'
- 'backend/**'
- 'frontend/**'
- '.dockerignore'
- 'docker-init.sh'
- 'Dockerfile'
pull_request:
paths:
- '.github/workflows/ci-build.yml'
- 'backend/**'
- 'frontend/**'
- '.dockerignore'
- 'docker-init.sh'
- 'Dockerfile'
- ".github/workflows/ci-build.yml"
- "backend/**"
- "frontend/apps/**"
- ".dockerignore"
- "docker-init.sh"
- "Dockerfile"
- "!**.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
build-images:
name: Validate Docker build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: build docker image
run: docker build --build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github .
- name: set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
docker system prune -af
- name: build docker image without pushing
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
load: true
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
uses: docker/build-push-action@v7
with:
context: .
file: backend/_example/memory_store/Dockerfile
platforms: linux/amd64
load: true
cache-from: type=gha,scope=example
cache-to: type=gha,scope=example,mode=max,ignore-error=true
build-args: |
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
@@ -1,19 +0,0 @@
name: frontend
on:
pull_request:
paths:
- '.github/workflows/ci-frontend-size-limit.yml'
- 'frontend/**'
jobs:
size:
runs-on: ubuntu-latest
env:
CI_JOB_NUMBER: 1
steps:
- uses: actions/checkout@v1
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
directory: frontend
+143 -53
View File
@@ -3,103 +3,193 @@ name: frontend
on:
push:
branches:
tags:
- master
paths:
- '.github/workflows/ci-frontend.yml'
- 'frontend/**'
- ".github/workflows/ci-frontend.yml"
- "frontend/**"
- "!**.md"
pull_request:
paths:
- '.github/workflows/ci-frontend.yml'
- 'frontend/**'
- ".github/workflows/ci-frontend.yml"
- "frontend/**"
- "!**.md"
jobs:
check-transtations:
translations-check:
name: Translations check
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [14.15]
node: [24]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Install node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- run: npm ci --loglevel warn
working-directory: ./frontend
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: ./frontend/apps/remark42
- uses: actions/cache@v2
with:
path: ${{ github.workspace }}/frontend/node_modules/.cache
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Translations check
run: pnpm translation-check
working-directory: ./frontend/apps/remark42
- run: npm run check:translation
working-directory: ./frontend
check-typescript:
type-check:
name: Type check
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [14.15]
node: [24]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Install node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- run: npm ci --loglevel warn
working-directory: ./frontend
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: ./frontend/apps/remark42
- uses: actions/cache@v2
with:
path: ${{ github.workspace }}/frontend/node_modules/.cache
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm run check:types
working-directory: ./frontend
- name: Run type check
run: pnpm type-check
working-directory: ./frontend/apps/remark42
lint:
name: Eslint & Stylelint
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [14.15]
node: [24]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Install node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- run: npm ci --loglevel warn
working-directory: ./frontend
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: ./frontend/apps/remark42
- run: npx run-p lint
working-directory: ./frontend
- name: Run linters
run: pnpm lint
working-directory: ./frontend/apps/remark42
size-limit:
name: Size limit
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
env:
CI_JOB_NUMBER: 1
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Check bundle size
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
directory: ./frontend/apps/remark42
package_manager: pnpm
test:
name: Tests & Coverage
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [14.15]
node: [24]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Install node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- run: npm ci --loglevel warn
working-directory: ./frontend
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: ./frontend/apps/remark42
- run: npm run test:coverage
working-directory: ./frontend
- name: Test & Coverage
run: pnpm coverage
working-directory: ./frontend/apps/remark42
- name: submit coverage
run: node ${{ github.workspace }}/frontend/node_modules/.bin/codecov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
working-directory: ./frontend/apps/remark42
codecov_yml_path: ./frontend/apps/remark42/codecov.yml
+175
View File
@@ -0,0 +1,175 @@
name: site
on:
release:
types: [published]
push:
branches:
- master
paths:
- ".github/workflows/ci-site.yml"
- "site/**"
- "!**/CLAUDE.md"
- "!site/README.md"
pull_request:
paths:
- ".github/workflows/ci-site.yml"
- "site/**"
- "!**/CLAUDE.md"
- "!site/README.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
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:
name: Build site image (${{ matrix.platform }})
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
artifact: linux-amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
artifact: linux-arm64
steps:
- name: checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: login to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.PKG_TOKEN }}
- name: build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: ./site
platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=site-${{ matrix.platform }}
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
- name: export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: upload digest
uses: actions/upload-artifact@v7
with:
name: site-digests-${{ matrix.artifact }}
path: /tmp/digests/*
retention-days: 1
merge:
name: Create site multi-arch manifest
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: site-digests-*
merge-multiple: true
- name: verify all digests present
run: |
expected=2
actual=$(find /tmp/digests -maxdepth 1 -type f | wc -l)
if [ "$actual" -ne "$expected" ]; then
echo "Expected $expected digests, found $actual"
ls -la /tmp/digests
exit 1
fi
echo "All $expected digests present"
- name: set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: login to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.PKG_TOKEN }}
- name: create manifest and push
working-directory: /tmp/digests
env:
GITHUB_REF: ${{ github.ref }}
run: |
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
docker buildx imagetools create \
-t ghcr.io/umputun/remark42-site:${ref} \
-t ghcr.io/umputun/remark42-site:latest \
$(printf 'ghcr.io/umputun/remark42-site@sha256:%s ' *)
else
docker buildx imagetools create \
-t ghcr.io/umputun/remark42-site:${ref} \
$(printf 'ghcr.io/umputun/remark42-site@sha256:%s ' *)
fi
deploy:
name: Deploy site
runs-on: ubuntu-latest
needs: merge
if: github.ref == 'refs/heads/master' || github.event_name == 'release'
permissions: {} # only calls an external URL via curl, no GitHub API access needed
steps:
- name: trigger deployment
env:
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
run: curl -sf https://jess.umputun.com/update/remark42-site/${UPDATER_KEY}
-63
View File
@@ -1,63 +0,0 @@
name: test_backend
on:
push:
branches:
tags:
paths:
- '.github/workflows/ci-test-backend.yml'
- 'backend/**'
- '!backend/scripts/**'
pull_request:
paths:
- '.github/workflows/ci-test-backend.yml'
- 'backend/**'
- '!backend/scripts/**'
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: debug if needed
run: if [[ "$DEBUG" == "true" ]]; then env; fi
env:
DEBUG: ${{secrets.DEBUG}}
- name: install go
uses: actions/setup-go@v1
with:
go-version: 1.14
- name: install golangci-lint and goveralls
run: |
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.26.0
go get -u github.com/mattn/goveralls
- name: test and lint backend
run: |
go test -race -timeout=60s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run --out-format=github-actions ./...
working-directory: backend/app
env:
GOFLAGS: "-mod=vendor"
TZ: "America/Chicago"
- name: test and lint examples
run: |
go version
$GITHUB_WORKSPACE/golangci-lint version
go test -race ./...
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run --out-format=github-actions ./...
working-directory: backend/_example/memory_store
env:
TZ: "America/Chicago"
- name: submit coverage
run: $(go env GOPATH)/bin/goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
working-directory: backend
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+215
View File
@@ -0,0 +1,215 @@
name: docker
on:
workflow_run:
workflows: [backend, frontend]
types: [completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
build:
name: Build Docker image (${{ matrix.platform }})
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event != 'pull_request' &&
(github.event.workflow_run.head_branch == 'master' ||
startsWith(github.event.workflow_run.head_branch, 'v'))
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
artifact: linux-amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
artifact: linux-arm64
runs-on: ${{ matrix.runner }}
steps:
- name: checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
- name: set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: login to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.PKG_TOKEN }}
- name: login to DockerHub
uses: docker/login-action@v4
with:
username: umputun
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
docker system prune -af
- name: build and push to ghcr.io by digest
id: build-ghcr
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
build-args: |
SKIP_BACKEND_TEST=true
SKIP_FRONTEND_TEST=true
CI=github
GITHUB_SHA=${{ github.event.workflow_run.head_sha }}
GIT_BRANCH=${{ github.event.workflow_run.head_branch }}
GITHUB_REF=refs/heads/${{ github.event.workflow_run.head_branch }}
outputs: type=image,name=ghcr.io/umputun/remark42,push-by-digest=true,name-canonical=true,push=true
- name: build and push to DockerHub by digest
id: build-dockerhub
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.platform }}
build-args: |
SKIP_BACKEND_TEST=true
SKIP_FRONTEND_TEST=true
CI=github
GITHUB_SHA=${{ github.event.workflow_run.head_sha }}
GIT_BRANCH=${{ github.event.workflow_run.head_branch }}
GITHUB_REF=refs/heads/${{ github.event.workflow_run.head_branch }}
outputs: type=image,name=umputun/remark42,push-by-digest=true,name-canonical=true,push=true
- name: export digests
run: |
mkdir -p /tmp/digests/ghcr /tmp/digests/dockerhub
digest_ghcr="${{ steps.build-ghcr.outputs.digest }}"
digest_dockerhub="${{ steps.build-dockerhub.outputs.digest }}"
touch "/tmp/digests/ghcr/${digest_ghcr#sha256:}"
touch "/tmp/digests/dockerhub/${digest_dockerhub#sha256:}"
- name: upload ghcr digest
uses: actions/upload-artifact@v7
with:
name: digests-ghcr-${{ matrix.artifact }}
path: /tmp/digests/ghcr/*
retention-days: 1
- name: upload dockerhub digest
uses: actions/upload-artifact@v7
with:
name: digests-dockerhub-${{ matrix.artifact }}
path: /tmp/digests/dockerhub/*
retention-days: 1
merge:
name: Create multi-arch manifest
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: download ghcr digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests/ghcr
pattern: digests-ghcr-*
merge-multiple: true
- name: download dockerhub digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests/dockerhub
pattern: digests-dockerhub-*
merge-multiple: true
- name: verify all digests present
run: |
expected=2
for registry in ghcr dockerhub; do
actual=$(find /tmp/digests/$registry -maxdepth 1 -type f | wc -l)
if [ "$actual" -ne "$expected" ]; then
echo "Expected $expected digests for $registry, found $actual"
ls -la /tmp/digests/$registry
exit 1
fi
done
echo "All digests present for both registries"
- name: set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: login to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.PKG_TOKEN }}
- name: login to DockerHub
uses: docker/login-action@v4
with:
username: umputun
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: create ghcr.io manifest and push
working-directory: /tmp/digests/ghcr
env:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
if [[ "$HEAD_BRANCH" == v* ]]; then
docker buildx imagetools create \
-t ghcr.io/umputun/remark42:${HEAD_BRANCH} \
-t ghcr.io/umputun/remark42:latest \
$(printf 'ghcr.io/umputun/remark42@sha256:%s ' *)
else
docker buildx imagetools create \
-t ghcr.io/umputun/remark42:${HEAD_BRANCH} \
$(printf 'ghcr.io/umputun/remark42@sha256:%s ' *)
fi
- name: create DockerHub manifest and push
working-directory: /tmp/digests/dockerhub
env:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
if [[ "$HEAD_BRANCH" == v* ]]; then
docker buildx imagetools create \
-t umputun/remark42:${HEAD_BRANCH} \
-t umputun/remark42:latest \
$(printf 'umputun/remark42@sha256:%s ' *)
else
docker buildx imagetools create \
-t umputun/remark42:${HEAD_BRANCH} \
$(printf 'umputun/remark42@sha256:%s ' *)
fi
deploy:
name: Deploy to remark42.com
runs-on: ubuntu-latest
needs: merge
if: github.event.workflow_run.head_branch == 'master'
permissions: {} # only calls an external URL via curl, no GitHub API access needed
steps:
- name: trigger deployment
env:
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
run: curl -sf https://jess.umputun.com/update/remark42-core/${UPDATER_KEY}
+124
View File
@@ -0,0 +1,124 @@
name: e2e
on:
push:
branches: [master]
paths:
- ".github/workflows/e2e-tests.yml"
- "backend/**"
- "frontend/**"
- "e2e/**"
- "compose-e2e-test.yml"
- "Dockerfile"
- "!**.md"
pull_request:
branches: [master]
paths:
- ".github/workflows/e2e-tests.yml"
- "backend/**"
- "frontend/**"
- "e2e/**"
- "compose-e2e-test.yml"
- "Dockerfile"
- "!**.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# cheap gate: catches a compile break or a lint regression in the build-tagged suite
# without paying for the docker build and the browser download
vet:
name: Vet
timeout-minutes: 10
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
- 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: |
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
if-no-files-found: ignore
+145
View File
@@ -0,0 +1,145 @@
name: release
on:
push:
tags:
- "v*"
pull_request:
paths:
- ".github/workflows/release.yml"
- ".goreleaser.yml"
- "Makefile"
- "scripts/**"
- "backend/**"
- "frontend/**"
- "!backend/**.md"
- "!frontend/**.md"
- "README.md"
- "LICENSE"
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: install go
uses: actions/setup-go@v7
with:
go-version: "1.25"
check-latest: true
cache-dependency-path: backend/go.sum
- name: install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: install node
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: test and build backend
run: |
go test -race -timeout=300s ./...
go build -race ./...
working-directory: backend/app
env:
TZ: "America/Chicago"
- name: test examples
run: |
go test -race ./...
go build -race ./...
working-directory: backend/_example/memory_store
env:
TZ: "America/Chicago"
- name: install frontend dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend/apps/remark42
env:
CI: "true"
- name: check frontend
run: |
pnpm lint
pnpm type-check
pnpm test --runInBand
working-directory: frontend/apps/remark42
env:
CI: "true"
- name: check goreleaser snapshot
if: github.event_name == 'pull_request'
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: release --snapshot --clean --skip=publish
env:
SKIP_PNPM_INSTALL: "true"
- name: clean generated release assets
if: always()
run: ./scripts/cleanup-release-assets.sh
release:
if: github.event_name == 'push'
needs: validate
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: install go
uses: actions/setup-go@v7
with:
go-version: "1.25"
check-latest: true
cache-dependency-path: backend/go.sum
- name: install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: install node
uses: actions/setup-node@v7
with:
node-version: 24
cache: "pnpm"
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
- name: install frontend dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend/apps/remark42
env:
CI: "true"
- name: run goreleaser
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SKIP_PNPM_INSTALL: "true"
- name: clean generated release assets
if: always()
run: ./scripts/cleanup-release-assets.sh
+11 -3
View File
@@ -8,9 +8,6 @@ debug
debug.test
.vscode
.idea/
/frontend/node_modules/
/frontend/public/
/frontend/coverage
*.prof
*.test
/rest-client.env.json
@@ -18,9 +15,20 @@ debug.test
.mongo
remark42
/bin/
/dist/
/backend/var/
/backend/app/var/
/backend/app/cmd/web/
/backend/*.html.tmpl
compose-private-backend.yml
compose-private-frontend.yml
compose-private.yml
/backend/_example/*/vendor
http-client.env.json
/backend/app/cmd/var
# ralphex progress logs
.ralphex/progress/
# traces from failed e2e runs
/e2e/traces/
+60
View File
@@ -0,0 +1,60 @@
version: 2
project_name: remark42
git:
ignore_tags:
- backend/*
before:
hooks:
- ./scripts/prepare-release-assets.sh
builds:
- id: remark42
dir: backend
main: ./app
binary: "remark42.{{ .Os }}-{{ .Arch }}"
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- freebsd
- windows
goarch:
- amd64
- arm64
- "386"
ignore:
- goos: darwin
goarch: "386"
- goos: freebsd
goarch: arm64
- goos: freebsd
goarch: "386"
- goos: windows
goarch: arm64
- goos: windows
goarch: "386"
ldflags:
- -s -w -X main.revision={{ .Tag }}-{{ .ShortCommit }}-{{ trimsuffix (replace (replace .CommitDate "-" "") ":" "") "Z" }}
archives:
- id: remark42
ids:
- remark42
name_template: "{{ .ProjectName }}.{{ .Os }}-{{ .Arch }}"
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
files:
- LICENSE
- README.md
release:
name_template: "Version {{ .Version }}"
mode: keep-existing
+100
View File
@@ -0,0 +1,100 @@
# Remark42 Development Guidelines
## Build/Test/Lint Commands
- **Backend**:
- Run server: `make rundev`
- Build: `make backend`
- Race test: `make race_test`
- **Backend Testing**:
- 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`
- **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`
- **Frontend**:
- Development: `cd frontend/apps/remark42 && pnpm dev`
- 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**:
- Backend: `cd backend && golangci-lint run`
- **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`
- Frontend: `cd frontend/apps/remark42 && pnpm lint`
- **Before committing**: Always run tests and linter on both main backend AND examples
- **Go module changes**:
- **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
Remark42 uses two tags for each release:
- `vX.Y.Z` - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.
- `backend/vX.Y.Z` - nested Go module tag for `github.com/umputun/remark42/backend`.
Release flow:
1. Create the GitHub release for `vX.Y.Z` with title `Version X.Y.Z`. The GitHub release must exist before the `vX.Y.Z` tag reaches the remote; `gh release create vX.Y.Z` satisfies this because it creates and pushes the tag.
2. The `vX.Y.Z` tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.
3. Create and push the matching backend module tag pointing at the same commit:
```bash
git fetch origin --tags
git tag backend/vX.Y.Z vX.Y.Z
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.
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
- **Backend**: Formatting with golangci-lint, strict error handling
- **Frontend**: TypeScript with ESLint, Stylelint and Prettier
- **Imports**: Group stdlib, external packages, then internal packages
- **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
- **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`
- **Auth**: `github.com/go-pkgz/auth/v2`
- **Logging**: `github.com/go-pkgz/lgr`
- **Testing**: `github.com/stretchr/testify`
- **Notifications**: `github.com/go-pkgz/notify`
## Repository Structure
- Backend: Go server using BoltDB for storage
- 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.
+78 -46
View File
@@ -1,82 +1,114 @@
FROM umputun/baseimage:buildgo-v1.6.1 as build-backend
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-deps
ARG SKIP_FRONTEND_TEST
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/apps/remark42/
COPY ./frontend/apps/remark42/package.json ./frontend/apps/remark42/pnpm-lock.yaml /srv/frontend/apps/remark42/
RUN \
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
apk add --no-cache --update git && \
npm i -g pnpm@10.10.0; \
fi
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
pnpm i; \
fi
FROM --platform=$BUILDPLATFORM frontend-deps AS build-frontend
ARG SKIP_FRONTEND_TEST
ARG SKIP_FRONTEND_BUILD
ENV CI=true
WORKDIR /srv/frontend/apps/remark42/
COPY ./frontend/apps/remark42/ /srv/frontend/apps/remark42/
RUN \
if [ -z "$SKIP_FRONTEND_TEST" ]; then \
pnpm lint type-check translation-check test; \
else \
echo 'Skip frontend test'; \
fi
RUN \
if [ -z "$SKIP_FRONTEND_BUILD" ]; then \
pnpm build; \
else \
mkdir /srv/frontend/apps/remark42/public; \
echo 'Skip frontend build'; \
fi
FROM umputun/baseimage:buildgo-v1.17.0 AS build-backend
ARG CI
ARG DRONE
ARG DRONE_TAG
ARG DRONE_COMMIT
ARG DRONE_BRANCH
ARG DRONE_PULL_REQUEST
ARG GITHUB_REF
ARG GITHUB_SHA
ARG GIT_BRANCH
ARG SKIP_BACKEND_TEST
ARG BACKEND_TEST_TIMEOUT
ADD backend /build/backend
ADD .git/ /build/backend/.git/
WORKDIR /build/backend
ENV GOFLAGS="-mod=vendor"
# install gcc in order to be able to go test package with -race
RUN apk --no-cache add gcc libc-dev
ADD backend /build/backend
# to embed the frontend files statically into Remark42 binary
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
WORKDIR /build/backend
RUN echo go version: `go version`
# run tests
RUN \
cd app && \
if [ -z "$SKIP_BACKEND_TEST" ] ; then \
CGO_ENABLED=1 go test -race -p 1 -timeout="${BACKEND_TEST_TIMEOUT:-300s}" -covermode=atomic -coverprofile=/profile.cov_tmp ./... && \
cat /profile.cov_tmp | grep -v "_mock.go" > /profile.cov ; \
cat /profile.cov_tmp | grep -v "_mock.go" > /profile.cov && \
golangci-lint run --config ../.golangci.yml ./... ; \
else echo "skip backend tests and linter" ; fi
else \
echo "skip backend tests and linter" \
; fi
# if DRONE presented use DRONE_* git env to make version
RUN \
if [ -z "$DRONE" ] ; then echo "runs outside of drone" && version="$(/script/git-rev.sh)" ; \
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S) ; fi && \
version="$(/script/version.sh)" && \
echo "version=$version" && \
go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app
FROM node:12.16-alpine as build-frontend-deps
FROM umputun/baseimage:app-v1.17.0
ARG CI
ENV HUSKY_SKIP_INSTALL=true
ARG GITHUB_SHA
RUN apk add --no-cache --update git
ADD frontend/package.json /srv/frontend/package.json
ADD frontend/package-lock.json /srv/frontend/package-lock.json
RUN cd /srv/frontend && CI=true npm ci --loglevel warn
FROM node:12.16-alpine as build-frontend
ARG CI
ARG SKIP_FRONTEND_TEST
ARG NODE_ENV=production
COPY --from=build-frontend-deps /srv/frontend/node_modules /srv/frontend/node_modules
ADD frontend /srv/frontend
RUN cd /srv/frontend && \
if [ -z "$SKIP_FRONTEND_TEST" ] ; then npx run-p lint test check; \
else echo "skip frontend tests and lint" ; npm run build ; fi && \
rm -rf ./node_modules
FROM umputun/baseimage:app-v1.6.1
LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
org.opencontainers.image.description="Remark42 comment engine" \
org.opencontainers.image.documentation="https://remark42.com/docs/getting-started/" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.source="https://github.com/umputun/remark42" \
org.opencontainers.image.title="Remark42" \
org.opencontainers.image.url="https://remark42.com/" \
org.opencontainers.image.revision="${GITHUB_SHA}"
WORKDIR /srv
ADD docker-init.sh /entrypoint.sh
COPY docker-init.sh /srv/init.sh
ADD backend/scripts/backup.sh /usr/local/bin/backup
ADD backend/scripts/restore.sh /usr/local/bin/restore
ADD backend/scripts/import.sh /usr/local/bin/import
RUN chmod +x /entrypoint.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
RUN chmod +x /srv/init.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
COPY --from=build-backend /build/backend/remark42 /srv/remark42
COPY --from=build-backend /build/backend/templates /srv
COPY --from=build-frontend /srv/frontend/public/ /srv/web
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /srv/web/
RUN chown -R app:app /srv
RUN ln -s /srv/remark42 /usr/bin/remark42
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost:8080/ping || exit 1
COPY docker-init.sh /srv/init.sh
RUN chmod +x /srv/init.sh
CMD ["/srv/remark42", "server"]
-105
View File
@@ -1,105 +0,0 @@
FROM node:12.16-alpine as build-frontend-deps
ARG CI
ARG DRONE
ARG DRONE_TAG
ARG DRONE_COMMIT
ARG DRONE_BRANCH
ENV SKIP_FRONTEND_TEST=true
RUN apk add --no-cache --update git
ADD frontend/package.json /srv/frontend/package.json
ADD frontend/package-lock.json /srv/frontend/package-lock.json
RUN cd /srv/frontend && CI=true npm ci
FROM node:12.16-alpine as build-frontend
ARG CI
ARG NODE_ENV=production
ENV SKIP_FRONTEND_TEST=true
ENV HUSKY_SKIP_INSTALL=true
COPY --from=build-frontend-deps /srv/frontend/node_modules /srv/frontend/node_modules
ADD frontend /srv/frontend
RUN cd /srv/frontend && \
npm run build && \
rm -rf ./node_modules
FROM umputun/baseimage:buildgo-latest as build-backend
ARG GITHUB_TOKEN
ENV SKIP_BACKEND_TEST=true
WORKDIR /build/backend
ADD backend /build/backend
ADD README.md /build/
ADD LICENSE /build/
ADD .git/ /build/backend/.git/
COPY --from=build-frontend /srv/frontend/public/ web
RUN \
export WEB_ROOT=/build/backend/web && \
find . -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \; && \
statik --src=${WEB_ROOT} --dest=/build/backend/app/rest -p api -f && \
statik --src=/build/backend/templates --dest=/build/backend/app -p templates -ns templates -f && \
ls -la /build/backend/app/templates/statik.go && \
ls -la /build/backend/app/rest/api/statik.go && \
ls -la /build/backend/web/
# if DRONE presented use DRONE_* git env to make version
RUN \
if [ -z "$DRONE" ] ; then \
echo "runs outside of drone" && version=$(/script/git-rev.sh); \
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S); fi && \
echo "version=$version" && \
export GOFLAGS="-mod=vendor" && \
GOOS=linux GOARCH=amd64 go build -o remark42.linux-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=linux GOARCH=386 go build -o remark42.linux-386 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=linux GOARCH=arm go build -o remark42.linux-arm -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=linux GOARCH=arm64 go build -o remark42.linux-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=windows GOARCH=amd64 go build -o remark42.windows-amd64.exe -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=darwin GOARCH=amd64 go build -o remark42.darwin-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=freebsd GOARCH=amd64 go build -o remark42.freebsd-amd64 -ldflags "-X main.revision=${version} -s -w" ./app
RUN \
if [ -z "$DRONE_TAG" ] ; then \
echo "runs outside of drone" && tag=""; \
else tag=_${DRONE_TAG}; fi && \
apk add --no-cache --update zip && \
cp ../LICENSE ./LICENSE && cp ../README.md ./README.md && \
tar cvzf remark42${tag}.linux-amd64.tar.gz remark42.linux-amd64 LICENSE README.md && \
tar cvzf remark42${tag}.linux-386.tar.gz remark42.linux-386 LICENSE README.md && \
tar cvzf remark42${tag}.linux-arm.tar.gz remark42.linux-arm LICENSE README.md && \
tar cvzf remark42${tag}.linux-arm64.tar.gz remark42.linux-arm64 LICENSE README.md && \
tar cvzf remark42${tag}.darwin-amd64.tar.gz remark42.darwin-amd64 LICENSE README.md && \
tar cvzf remark42${tag}.freebsd-amd64.tar.gz remark42.freebsd-amd64 LICENSE README.md && \
zip remark42${tag}.windows-amd64.zip remark42.windows-amd64.exe LICENSE README.md
# upload to github
#RUN \
# if [ -z "$DRONE_TAG" ] ; then \
# echo "skip upload to github" ; \
# else \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-amd64.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-amd64.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-386.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-386.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-arm64.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-arm64.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.darwin-amd64.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.darwin-amd64.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/zip" --data-binary @remark42_${DRONE_TAG}.windows-amd64.zip \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.windows-amd64.zip"; fi
FROM alpine
COPY --from=build-backend /build/backend/remark42.* /artifacts/
RUN ls -la /artifacts/*
CMD ["sleep", "100"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 Umputun
Copyright (c) 2021 Umputun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+41 -24
View File
@@ -1,41 +1,58 @@
OS=linux
ARCH=amd64
GITHUB_REF=$(shell git rev-parse --symbolic-full-name HEAD)
GITHUB_SHA=$(shell git rev-parse --short HEAD)
CLEANUP_RELEASE_ASSETS=$(CURDIR)/scripts/cleanup-release-assets.sh
bin:
docker build -f Dockerfile.artifacts -t remark42.bin .
- @docker rm -f remark42.bin 2>/dev/null || exit 0
docker run -d --name=remark42.bin remark42.bin
docker cp remark42.bin:/artifacts/remark42.$(OS)-$(ARCH) remark42
docker rm -f remark42.bin
@set -e; \
./scripts/prepare-release-assets.sh; \
trap '$(CLEANUP_RELEASE_ASSETS)' EXIT; \
cd backend && CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ../remark42 -ldflags "-X main.revision=$(GITHUB_REF)-$(GITHUB_SHA) -s -w" ./app
docker:
docker build -t umputun/remark42 --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true .
DOCKER_BUILDKIT=1 docker build -t umputun/remark42 -t ghcr.io/umputun/remark42 --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 .
dockerx:
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 \
--progress=plain --platform linux/amd64,linux/arm64 \
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
release:
docker build -f Dockerfile.artifacts -t remark42.bin .
- @docker rm -f remark42.bin 2>/dev/null || exit 0
- @mkdir -p bin
docker run -d --name=remark42.bin remark42.bin
docker cp remark42.bin:/artifacts/remark42.linux-amd64.tar.gz bin/remark42.linux-amd64.tar.gz
docker cp remark42.bin:/artifacts/remark42.linux-386.tar.gz bin/remark42.linux-386.tar.gz
docker cp remark42.bin:/artifacts/remark42.linux-arm64.tar.gz bin/remark42.linux-arm64.tar.gz
docker cp remark42.bin:/artifacts/remark42.darwin-amd64.tar.gz bin/remark42.darwin-amd64.tar.gz
docker cp remark42.bin:/artifacts/remark42.freebsd-amd64.tar.gz bin/remark42.freebsd-amd64.tar.gz
docker cp remark42.bin:/artifacts/remark42.windows-amd64.zip bin/remark42.windows-amd64.zip
docker rm -f remark42.bin
@set -e; \
trap '$(CLEANUP_RELEASE_ASSETS)' EXIT; \
goreleaser release --snapshot --clean --skip=publish
race_test:
cd backend/app && go test -race -mod=vendor -timeout=60s -count 1 ./...
cd backend/app && go test -race -timeout=300s -count 1 ./...
backend:
docker-compose -f compose-dev-backend.yml build
docker compose -f compose-dev-backend.yml build
frontend:
docker-compose -f compose-dev-frontend.yml build
docker compose -f compose-dev-frontend.yml build
rundev:
docker pull umputun/baseimage:buildgo-latest
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true docker-compose -f compose-private.yml build
docker-compose -f compose-private.yml up
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true GITHUB_REF=$(GITHUB_REF) GITHUB_SHA=$(GITHUB_SHA) CI=true \
docker compose -f compose-private.yml build
docker compose -f compose-private.yml up
.PHONY: bin backend
# stamped the same way the suite stamps a stack it starts itself, so one brought up here is
# accepted instead of rejected as belonging to another checkout
e2e-up:
E2E_STAMP=$$(./e2e/stamp.sh) docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
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
+18 -865
View File
@@ -1,11 +1,8 @@
# remark42 [![Build Status](https://github.com/umputun/remark42/workflows/build/badge.svg)](https://github.com/umputun/remark42/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/umputun/remark42)](https://goreportcard.com/report/github.com/umputun/remark42) [![Coverage Status](https://coveralls.io/repos/github/umputun/remark42/badge.svg?branch=master)](https://coveralls.io/github/umputun/remark42?branch=master) [![codecov](https://codecov.io/gh/umputun/remark42/branch/master/graph/badge.svg)](https://codecov.io/gh/umputun/remark42)
# Remark42 [![Build Status](https://github.com/umputun/remark42/workflows/build/badge.svg)](https://github.com/umputun/remark42/actions) [![Image Size](https://img.shields.io/docker/image-size/umputun/remark42/master)](https://hub.docker.com/r/umputun/remark42) [![Go Report Card](https://goreportcard.com/badge/github.com/umputun/remark42)](https://goreportcard.com/report/github.com/umputun/remark42) [![Coverage Status](https://coveralls.io/repos/github/umputun/remark42/badge.svg?branch=master)](https://coveralls.io/github/umputun/remark42?branch=master) [![codecov](https://codecov.io/gh/umputun/remark42/branch/master/graph/badge.svg)](https://app.codecov.io/gh/umputun/remark42)
Remark42 is a self-hosted, lightweight and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles, or any other place where readers add comments.
Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles or any other place where readers add comments.
* Social login via Google, Twitter, Facebook, Microsoft, GitHub and Yandex
* Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Discord, Telegram and custom OAuth2 providers
* Login via email
* Optional anonymous access
* Multi-level nested comments with both tree and plain presentations
@@ -17,880 +14,36 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
* Images upload with drag-and-drop
* Extractor for recent comments, cross-post
* RSS for all comments and each post
* Telegram, Slack and email notifications for Admins (get notified for each new comment)
* Email notifications for users (get notified when someone responds to your comment)
* Export data to json with automatic backups
* Telegram, Slack, Webhook and email notifications for Admins (get notified for each new comment)
* Email and Telegram notifications for users (get notified when someone responds to your comment)
* Export data to JSON with automatic backups
* No external databases, everything embedded in a single data file
* Fully dockerized and can be deployed in a single command
* Self-contained executable can be deployed directly to Linux, Windows and MacOS
* Self-contained executable can be deployed directly to Linux, Windows and macOS
* Clean, lightweight and customizable UI with white and dark themes
* Multi-site mode from a single instance
* Integration with automatic ssl (direct and via [nginx-le](https://github.com/umputun/nginx-le))
* [Privacy focused](#privacy)
* Integration with automatic SSL (direct and via [nginx-le](https://github.com/nginx-le/nginx-le))
* [Privacy focused](https://remark42.com/#privacy)
[Demo site](https://remark42.com/demo/) available with all authentication methods, including email auth and anonymous access.
<details><summary>Screenshots</summary>
Comments example:
![](https://github.com/umputun/remark42/blob/master/screenshots/comments.png)
![](screenshots/comments.png)
For admin screenshots see [Admin UI wiki](https://github.com/umputun/remark42/wiki/Admin-UI)
For admin screenshots see [Admin UI documentation](https://remark42.com/docs/manuals/admin-interface/)
</details>
All remark42 documentation is available [by the link](https://remark42.com/docs/getting-started/installation/).
#
## Contribution
- [Install](#install)
- [Backend](#backend)
- [With Docker](#with-docker)
- [Without Docker](#without-docker)
- [Parameters](#parameters)
- [Required parameters](#required-parameters)
- [Quick installation test](#quick-installation-test)
- [Register oauth2 providers](#register-oauth2-providers)
- [Google Auth Provider](#google-auth-provider)
- [GitHub Auth Provider](#github-auth-provider)
- [Facebook Auth Provider](#facebook-auth-provider)
- [Twitter Auth Provider](#twitter-auth-provider)
- [Yandex Auth Provider](#yandex-auth-provider)
- [Initial import from Disqus](#initial-import-from-disqus)
- [Initial import from WordPress](#initial-import-from-wordpress)
- [Backup and restore](#backup-and-restore)
- [Automatic backups](#automatic-backups)
- [Manual backup](#manual-backup)
- [Restore from backup](#restore-from-backup)
- [Backup format](#backup-format)
- [Admin users](#admin-users)
- [Setup on your website](#setup-on-your-website)
- [Comments](#comments)
- [Last comments](#last-comments)
- [Counter](#counter)
- [Build from the source](#build-from-the-source)
- [Development](#development)
- [Backend development](#backend-development)
- [Frontend development](#frontend-development)
- [Build](#build)
- [Devserver](#devserver)
- [API](#api)
- [Authorization](#authorization)
- [Commenting](#commenting)
- [RSS feeds](#rss-feeds)
- [Admin](#admin)
- [Privacy](#privacy)
- [Technical details](#technical-details)
In order to start and work on the project locally in development mode check our contribution documentation for [backend](https://remark42.com/docs/contributing/backend/) and [frontend](https://remark42.com/docs/contributing/frontend/).
If you are interested in adding a new localization please check [these docs](https://remark42.com/docs/contributing/translations/).
## Install
## Related projects
### Backend
#### With Docker
_this is the recommended way to run remark42_
* copy provided `docker-compose.yml` and customize for your needs
* make sure you **don't keep** `ADMIN_PASSWD=something...` for any non-development deployments
* pull prepared images from the DockerHub and start - `docker-compose pull && docker-compose up -d`
* alternatively compile from the sources - `docker-compose build && docker-compose up -d`
#### Without Docker
* download archive for [stable release](https://github.com/umputun/remark42/releases) or [development version](https://remark42.com/downloads)
* unpack with `gunzip` (Linux, macOS) or with `zip` (Windows)
* run as `remark42.{os}-{arch} server {parameters...}`, i.e. `remark42.linux-amd64 server --secret=12345 --url=http://127.0.0.1:8080`
* alternatively compile from the sources - `make OS=[linux|darwin|windows] ARCH=[amd64,386,arm64,arm]`
#### Parameters
| Command line | Environment | Default | Description |
| ----------------------- | ----------------------- | ------------------------ | ----------------------------------------------- |
| url | REMARK_URL | | url to remark42 server, _required_ |
| secret | SECRET | | secret key, _required_ |
| site | SITE | `remark` | site name(s), _multi_ |
| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `rpc` |
| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory |
| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout |
| admin.shared.id | ADMIN_SHARED_ID | | admin ids (list of user ids), _multi_ |
| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin emails, _multi_ |
| backup | BACKUP_PATH | `./var/backup` | backups location |
| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep |
| cache.type | CACHE_TYPE | `mem` | type of cache, `redis_pub_sub` or `mem` or `none` |
| cache.redis_addr | CACHE_REDIS_ADDR | `127.0.0.1:6379` | address of redis PubSub instance, turn `redis_pub_sub` cache on for distributed cache |
| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited |
| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited |
| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited |
| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, `bolt`, or `uri` |
| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store |
| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store |
| avatar.uri | AVATAR_URI | `./var/avatars` | avatar store uri |
| avatar.rsz-lmt | AVATAR_RSZ_LMT | `0` (disabled) | max image size for resizing avatars on save |
| image.type | IMAGE_TYPE | `fs` | type of image storage, `fs`, `bolt` |
| image.max-size | IMAGE_MAX_SIZE | `5000000` | max size of image file |
| image.fs.path | IMAGE_FS_PATH | `./var/pictures` | permanent location of images |
| image.fs.staging | IMAGE_FS_STAGING | `./var/pictures.staging` | staging location of images |
| image.fs.partitions | IMAGE_FS_PARTITIONS | `100` | number of image partitions |
| image.bolt.file | IMAGE_BOLT_FILE | `/var/pictures.db` | images bolt file location |
| image.resize-width | IMAGE_RESIZE_WIDTH | `2400` | width of resized image |
| image.resize-height | IMAGE_RESIZE_HEIGHT | `900` | height of resized image |
| auth.ttl.jwt | AUTH_TTL_JWT | `5m` | jwt TTL |
| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | cookie TTL |
| auth.send-jwt-header | AUTH_SEND_JWT_HEADER | `false` | send JWT as a header instead of cookie |
| auth.same-site | AUTH_SAME_SITE | `default` | set same site policy for cookies (`default`, `none`, `lax` or `strict`)|
| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID |
| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret |
| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID |
| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret |
| auth.microsoft.cid | AUTH_MICROSOFT_CID | | Microsoft OAuth client ID |
| auth.microsoft.csec | AUTH_MICROSOFT_CSEC | | Microsoft OAuth client secret |
| auth.github.cid | AUTH_GITHUB_CID | | GitHub OAuth client ID |
| auth.github.csec | AUTH_GITHUB_CSEC | | GitHub OAuth client secret |
| auth.twitter.cid | AUTH_TWITTER_CID | | Twitter Consumer API Key |
| auth.twitter.csec | AUTH_TWITTER_CSEC | | Twitter Consumer API Secret key |
| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID |
| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret |
| auth.dev | AUTH_DEV | `false` | local oauth2 server, development mode only |
| auth.anon | AUTH_ANON | `false` | enable anonymous login |
| auth.email.enable | AUTH_EMAIL_ENABLE | `false` | enable auth via email |
| auth.email.from | AUTH_EMAIL_FROM | | email from |
| auth.email.subj | AUTH_EMAIL_SUBJ | `remark42 confirmation` | email subject |
| auth.email.content-type | AUTH_EMAIL_CONTENT_TYPE | `text/html` | email content type |
| auth.email.template | AUTH_EMAIL_TEMPLATE | none (predefined) | custom email message template file |
| notify.type | NOTIFY_TYPE | none | type of notification (telegram, slack and/or email) |
| notify.queue | NOTIFY_QUEUE | `100` | size of notification queue |
| notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token |
| notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel |
| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout |
| notify.slack.token | NOTIFY_SLACK_TOKEN | | slack token |
| notify.slack.chan | NOTIFY_SLACK_CHAN | `general` | slack channel |
| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address |
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject |
| notify.email.notify_admin | NOTIFY_EMAIL_ADMIN | `false` | notify admin on new comments via ADMIN_SHARED_EMAIL |
| smtp.host | SMTP_HOST | | SMTP host |
| smtp.port | SMTP_PORT | | SMTP port |
| smtp.username | SMTP_USERNAME | | SMTP user name |
| smtp.password | SMTP_PASSWORD | | SMTP password |
| smtp.tls | SMTP_TLS | | enable TLS for SMTP |
| smtp.timeout | SMTP_TIMEOUT | `10s` | SMTP TCP connection timeout |
| ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le |
| ssl.port | SSL_PORT | `8443` | port for https server |
| ssl.cert | SSL_CERT | | path to cert.pem file |
| ssl.key | SSL_KEY | | path to key.pem file |
| ssl.acme-location | SSL_ACME_LOCATION | `./var/acme` | dir where obtained le-certs will be stored |
| ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE |
| max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit |
| max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited |
| votes-ip | VOTES_IP | `false` | restrict votes from the same ip |
| anon-vote | ANON_VOTE | `false` | allow voting for anonymous users, require VOTES_IP to be enabled as well |
| votes-ip-time | VOTES_IP_TIME | `5m` | same ip vote restriction time, `0s` - unlimited |
| low-score | LOW_SCORE | `-5` | low score threshold |
| critical-score | CRITICAL_SCORE | `-10` | critical score threshold |
| positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive |
| restricted-words | RESTRICTED_WORDS | | words banned in comments (can use `*`), _multi_ |
| restricted-names | RESTRICTED_NAMES | | names prohibited to use by the user, _multi_ |
| edit-time | EDIT_TIME | `5m` | edit window |
| read-age | READONLY_AGE | | read-only age of comments, days |
| image-proxy.http2https | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images |
| image-proxy.cache-external | IMAGE_PROXY_CACHE_EXTERNAL | `false` | enable caching external images to current image storage |
| emoji | EMOJI | `false` | enable emoji support |
| simple-view | SIMPLE_VIEW | `false` | minimized UI with basic info only |
| proxy-cors | PROXY_CORS | `false` | disable internal CORS and delegate it to proxy |
| allowed-hosts | ALLOWED_HOSTS | enable all | limit hosts/sources allowed to embed comments |
| port | REMARK_PORT | `8080` | web server port |
| web-root | REMARK_WEB_ROOT | `./web` | web server root directory |
| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit |
| admin-passwd | ADMIN_PASSWD | none (disabled) | password for `admin` basic auth |
| dbg | DEBUG | `false` | debug mode |
* command line parameters are long form `--<key>=value`, i.e. `--site=https://demo.remark42.com`
* _multi_ parameters separated by `,` in the environment or repeated with command line key, like `--site=s1 --site=s2 ...`
* _required_ parameters have to be presented in the environment or provided in command line
##### Deprecated
Following list of command-line options is deprecated and will be removed in 2 minor releases or 1 major release (whichever is closer)
from the version in which they were deprecated. After remark42 version update, please check startup log once for deprecation warnings to avoid
trouble with unrecognized command-line options in the future.
<details>
<summary>deprecated options</summary>
| Command line | Replacement | Environment | Replacement | Default | Description | Deprecation version |
| ------------------ | ------------- | ------------------ | ------------- | ------- | -------------- | ------------------- |
| auth.email.host | smtp.host | AUTH_EMAIL_HOST | SMTP_HOST | | smtp host | 1.5.0 |
| auth.email.port | smtp.port | AUTH_EMAIL_PORT | SMTP_PORT | | smtp port | 1.5.0 |
| auth.email.user | smtp.username | AUTH_EMAIL_USER | SMTP_USERNAME | | smtp user name | 1.5.0 |
| auth.email.passwd | smtp.password | AUTH_EMAIL_PASSWD | SMTP_PASSWORD | | smtp password | 1.5.0 |
| auth.email.tls | smtp.tls | AUTH_EMAIL_TLS | SMTP_TLS | `false` | enable TLS | 1.5.0 |
| auth.email.timeout | smtp.timeout | AUTH_EMAIL_TIMEOUT | SMTP_TIMEOUT | `10s` | smtp timeout | 1.5.0 |
| img-proxy | image-proxy.http2https | IMG_PROXY | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images | 1.5.0 |
</details>
##### Required parameters
Most of the parameters have sane defaults and don't require customization. There are only a few parameters user has to define:
1. `SECRET` - secret key, can be any long and hard-to-guess string.
2. `REMARK_URL` - url pointing to your remark42 server, i.e. `https://demo.remark42.com`
3. At least one pair of `AUTH_<PROVIDER>_CID` and `AUTH_<PROVIDER>_CSEC` defining oauth2 provider(s)
The minimal `docker-compose.yml` has to include all required parameters:
```yaml
version: '2'
services:
remark42:
image: umputun/remark42:latest
restart: always
container_name: "remark42"
environment:
- REMARK_URL=https://demo.remark42.com # url pointing to your remark42 server
- SITE=YOUR_SITE_ID # site ID, same as used for `site_id`, see "Setup on your website"
- SECRET=abcd-123456-xyz-$%^& # secret key
- AUTH_GITHUB_CID=12345667890 # oauth2 client ID
- AUTH_GITHUB_CSEC=abcdefg12345678 # oauth2 client secret
volumes:
- ./var:/srv/var # persistent volume to store all remark42 data
```
#### Quick installation test
To verify if remark has been properly installed, check a demo page at `${REMARK_URL}/web` URL. Make sure to include `remark` site id to `${SITE}` list.
#### Register oauth2 providers
Authentication handled by external providers. You should setup oauth2 for all (or some) of them to allow users to make comments. It is not mandatory to have all of them, but at least one should be correctly configured.
##### Google Auth Provider
1. Create a new project: https://console.developers.google.com/project
1. Choose the new project from the top right project dropdown (only if another project is selected)
1. In the project Dashboard center pane, choose **"API Manager"**
1. In the left Nav pane, choose **"Credentials"**
1. In the center pane, choose **"OAuth consent screen"** tab. Fill in **"Product name shown to users"** and hit save.
1. In the center pane, choose **"Credentials"** tab.
* Open the **"New credentials"** drop down
* Choose **"OAuth client ID"**
* Choose **"Web application"**
* Application name is freeform, choose something appropriate
* Authorized origins is your domain ex: `https://remark42.mysite.com`
* Authorized redirect URIs is the location of oauth2/callback constructed as domain + `/auth/google/callback`, ex: `https://remark42.mysite.com/auth/google/callback`
* Choose **"Create"**
1. Take note of the **Client ID** and **Client Secret**
_instructions for google oauth2 setup borrowed from [oauth2_proxy](https://github.com/bitly/oauth2_proxy)_
##### GitHub Auth Provider
1. Create a new **"OAuth App"**: https://github.com/settings/developers
1. Fill **"Application Name"** and **"Homepage URL"** for your site
1. Under **"Authorization callback URL"** enter the correct url constructed as domain + `/auth/github/callback`. ie `https://remark42.mysite.com/auth/github/callback`
1. Take note of the **Client ID** and **Client Secret**
##### Facebook Auth Provider
1. From https://developers.facebook.com select **"My Apps"** / **"Add a new App"**
1. Set **"Display Name"** and **"Contact email"**
1. Choose **"Facebook Login"** and then **"Web"**
1. Set "Site URL" to your domain, ex: `https://remark42.mysite.com`
1. Under **"Facebook login"** / **"Settings"** fill "Valid OAuth redirect URIs" with your callback url constructed as domain + `/auth/facebook/callback`
1. Select **"App Review"** and turn public flag on. This step may ask you to provide a link to your privacy policy.
#### Microsoft Auth Provider
1. Register a new application [using the Azure portal](https://docs.microsoft.com/en-us/graph/auth-register-app-v2).
2. Under **"Authentication/Platform configurations/Web"** enter the correct url constructed as domain + `/auth/microsoft/callback`. i.e. `https://example.mysite.com/auth/microsoft/callback`
3. In "Overview" take note of the **Application (client) ID**
4. Choose the new project from the top right project dropdown (only if another project is selected)
5. Select "Certificates & secrets" and click on "+ New Client Secret".
##### Twitter Auth Provider
1. Create a new twitter application https://developer.twitter.com/en/apps
1. Fill **App name**, **Description** and **URL** of your site
1. In the field **Callback URLs** enter the correct url of your callback handler e.g. domain + `/auth/twitter/callback`
1. Under **Key and tokens** take note of the **Consumer API Key** and **Consumer API Secret key**. Those will be used as `AUTH_TWITTER_CID` and
`AUTH_TWITTER_CSEC`
##### Yandex Auth Provider
1. Create a new **"OAuth App"**: https://oauth.yandex.com/client/new
1. Fill **"App name"** for your site
1. Under **Platforms** select **"Web services"** and enter **"Callback URI #1"** constructed as domain + `/auth/yandex/callback`. ie `https://remark42.mysite.com/auth/yandex/callback`
1. Select **Permissions**. You need following permissions only from the **"Yandex.Passport API"** section:
* Access to user avatar
* Access to username, first name and surname, gender
1. Fill out the rest of fields if needed
1. Take note of the **ID** and **Password**
For more details refer to [Yandex OAuth](https://tech.yandex.com/oauth/doc/dg/concepts/about-docpage/) and [Yandex.Passport](https://tech.yandex.com/passport/doc/dg/index-docpage/) API documentation.
##### Anonymous Auth Provider
Optionally, anonymous access can be turned on. In this case an extra `anonymous` provider will allow logins without any social login with any name satisfying 2 conditions:
- name should be at least 3 characters long
- name has to start from the letter and contains letters, numbers, underscores and spaces only.
### Importing comments
Remark supports importing comments from Disqus, WordPress or native backup format.
All imported comments has `Imported` field set to `true`.
#### Initial import from Disqus
1. Disqus provides an export of all comments on your site in a g-zipped file. This is found in your Moderation panel at Disqus Admin > Setup > Export. The export will be sent into a queue and then emailed to the address associated with your account once it's ready. Direct link to export will be something like `https://<siteud>.disqus.com/admin/discussions/export/`. See [importing-exporting](https://help.disqus.com/customer/portal/articles/1104797-importing-exporting) for more details.
2. Move this file to your remark42 host within `./var` and unzip, i.e. `gunzip <disqus-export-name>.xml.gz`.
3. Run import command - `docker exec -it remark42 import -p disqus -f /srv/var/{disqus-export-name}.xml -s {your site id}`
#### Initial import from WordPress
1. Install WordPress [plugin](https://wordpress.org/plugins/wp-exporter/) to export comments and follow it instructions. The plugin should produce a xml-based file with site content including comments.
2. Move this file to your remark42 host within `./var`
3. Run import command - `docker exec -it remark42 import -p wordpress -f {wordpress-export-name}.xml -s {your site id}`
#### Backup and restore
##### Automatic backups
Remark42 by default makes daily backup files under `${BACKUP_PATH}` (default `./var/backup`). Backups kept up to `${MAX_BACKUP_FILES}` (default 10). Each backup file contains exported and gzipped content, i.e., all comments. At any point, the user can restore such backup and revert all comments to the desirable state. Note: restore procedure cleans the current data store and replaces all comments with comments from the backup file.
For safety and security reasons restore functionality not exposed outside of your server by default. The recommended way to restore from the backup is to use provided `scripts/restore-backup.sh`. It can run inside the container:
`docker exec -it remark42 restore -f {backup-filename.gz} -s {your site id}`
##### Manual backup
In addition to automatic backups user can make a backup manually. This command makes `userbackup-{site id}-{timestamp}.gz` by default.
`docker exec -it remark42 backup -s {your site id}`
##### Restore from backup
Restore will clean all comments first and then will processed with complete import from a given file.
`docker exec -it remark42 restore -f {backup file name} -s {your site id}`
##### Backup format
Backup file is a text file with all exported comments separated by EOL. Each backup record is a valid json with all key/value
unmarshaled from `Comment` struct (see below).
#### Admin users
Admins/moderators should be defined in `docker-compose.yml` as a list of user IDs or passed in the command line.
```
environment:
- ADMIN_SHARED_ID=github_ef0f706a79cc24b17bbbb374cd234a691a034128,github_dae9983158e9e5e127ef2b87a411ef13c891e9e5
```
To get user id just login and click on your username or any other user you want to promote to admins.
It will expand login info and show full user ID.
#### Docker parameters
Two parameters allow customizing Docker container on the system level:
- `APP_UID` - sets UID to run remark42 application in container (default=1001)
- `TIME_ZONE` - sets time zone of remark42 container (default=America/Chicago)
_see [umputun/baseimage](https://github.com/umputun/baseimage) for more details_
example of `docker-compose.yml`:
```yaml
version: '2'
services:
remark42:
image: umputun/remark42:latest
restart: always
container_name: "remark42"
environment:
- APP_UID=2000 # runs remark42 app with non-default UID
- TIME_ZONE=GTC # sets container time to UTC
- REMARK_URL=https://demo.remark42.com # url pointing to your remark42 server
- SITE=YOUR_SITE_ID # site ID, same as used for `site_id`, see "Setup on your website"
- SECRET=abcd-123456-xyz-$%^& # secret key
- AUTH_GITHUB_CID=12345667890 # oauth2 client ID
- AUTH_GITHUB_CSEC=abcdefg12345678 # oauth2 client secret
volumes:
- ./var:/srv/var # persistent volume to store all remark42 data
```
### Setup on your website
#### Comments
It's a main widget which renders list of comments.
Add this snippet to the bottom of web page:
```html
<script>
var remark_config = {
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
site_id: 'YOUR_SITE_ID',
components: ['embed'], // optional param; which components to load. default to ["embed"]
// to load all components define components as ['embed', 'last-comments', 'counter']
// available component are:
// - 'embed': basic comments widget
// - 'last-comments': last comments widget, see `Last Comments` section below
// - 'counter': counter widget, see `Counter` section below
url: 'PAGE_URL', // optional param; if it isn't defined
// `window.location.origin + window.location.pathname` will be used,
//
// Note that if you use query parameters as significant part of url
// (the one that actually changes content on page)
// you will have to configure url manually to keep query params, as
// `window.location.origin + window.location.pathname` doesn't contain query params and
// hash. For example default url for `https://example/com/example-post?id=1#hash`
// would be `https://example/com/example-post`.
//
// The problem with query params is that they often contain useless params added by
// various trackers (utm params) and doesn't have defined order, so Remark treats differently
// all this examples:
// https://example.com/?postid=1&date=2007-02-11
// https://example.com/?date=2007-02-11&postid=1
// https://example.com/?date=2007-02-11&postid=1&utm_source=google
//
// If you deal with query parameters make sure you pass only significant part of it
// in well defined order
max_shown_comments: 10, // optional param; if it isn't defined default value (15) will be used
theme: 'dark', // optional param; if it isn't defined default value ('light') will be used
page_title: 'Moving to Remark42', // optional param; if it isn't defined `document.title` will be used
locale: 'en', // set up locale and language, if it isn't defined default value ('en') will be used
show_email_subscription: false // optional param; by default it is `true` and you can see email subscription feature
// in interface when enable it from backend side
// if you set this param in `false` you will get notifications email notifications as admin
// but your users won't have interface for subscription
};
</script>
<script>!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),c=".js",d=n.head||n.body;"noModule"in r?(r.type="module",c=".mjs"):r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+c,d.appendChild(r)}}(remark_config.components||["embed"],document);</script>
```
And then add this node in the place where you want to see Remark42 widget:
```html
<div id="remark42"></div>
```
After that widget will be rendered inside this node.
If you want to set this up on a Single Page App, see [appropriate doc page](https://remark42.com/docs/latest/spa/).
##### Themes
Right now Remark has two themes: light and dark.
You can pick one using configuration object,
but there is also a possibility to switch between themes in runtime.
For this purpose Remark adds to `window` object named `REMARK42`,
which contains function `changeTheme`.
Just call this function and pass a name of the theme that you want to turn on:
```js
window.REMARK42.changeTheme('light');
```
##### Locales
Right now Remark is translated to en, ru (partially), de, and fi languages.
You can pick one using [configuration object](#setup-on-your-website).
Do you want translate remark42 to other locale? Please see [this documentation](https://github.com/umputun/remark42/blob/master/docs/translation.md) for details.
#### Last comments
It's a widget which renders list of last comments from your site.
Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `last-comments` in `components` list:
```html
<script>
var remark_config = {
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
site_id: 'YOUR_SITE_ID',
components: ['last-comments']
};
</script>
```
And then add this node in the place where you want to see last comments widget:
```html
<div class="remark42__last-comments" data-max="50"></div>
```
`data-max` sets the max amount of comments (default: `15`).
#### Counter
It's a widget which renders a number of comments for the specified page.
Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `counter` in `components` list:
```html
<script>
var remark_config = {
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
site_id: 'YOUR_SITE_ID',
components: ['counter']
};
</script>
```
And then add a node like this in the place where you want to see a number of comments:
```html
<span class="remark42__counter" data-url="https://domain.com/path/to/article/"></span>
```
You can use as many nodes like this as you need to.
The script will found all them by the class `remark__counter`,
and it will use `data-url` attribute to define the page with comments.
Also script can use `url` property from `remark_config` object, or `window.location.origin + window.location.pathname` if nothing else is defined.
## Build from the source
- to build Docker container - `make docker`. This command will produce container `umputun/remark42`.
- to build a single binary for direct execution - `make OS=<linux|windows|darwin> ARCH=<amd64|386>`. This step will produce executable
`remark42` file with everything embedded.
## Development
You can use fully functional local version to develop and test both frontend & backend. It requires at least 2GB RAM or swap enabled
To bring it up run:
```bash
# if you mainly work on backend
cp compose-dev-backend.yml compose-private.yml
# if you mainly work on frontend
cp compose-dev-frontend.yml compose-private.yml
# now, edit / debug `compose-private.yml` to your heart's content.
# build and run
docker-compose -f compose-private.yml build
docker-compose -f compose-private.yml up
```
It starts Remark42 on `127.0.0.1:8080` and adds local OAuth2 provider “Dev”.
To access UI demo page go to `127.0.0.1:8080/web`.
By default, you would be logged in as `dev_user` which defined as admin.
You can tweak any of [supported parameters](#Parameters) in corresponded yml file.
Backend Docker Compose config by default skips running frontend related tests.
Frontend Docker Compose config by default skips running backend related tests and sets `NODE_ENV=development` for frontend build.
### Backend development
In order to run backend locally (development mode, without Docker) you have to have the latest stable `go` toolchain [installed](https://golang.org/doc/install).
To run backend - `cd backend; go run app/main.go server --dbg --secret=12345 --url=http://127.0.0.1:8080 --admin-passwd=password --site=remark`
It stars backend service with embedded bolt store on port `8080` with basic auth, allowing to authenticate and run requests directly, like this:
`HTTP http://admin:password@127.0.0.1:8080/api/v1/find?site=remark&sort=-active&format=tree&url=http://127.0.0.1:8080`
### Frontend development
#### Developer guide
Frontend guide can be found here: [./frontend/README.md](./frontend/README.md)
#### Build
You should have at least 2GB RAM or swap enabled for building
* install [Node.js 12.11](https://nodejs.org/en/) or higher;
* install [NPM 6.13.4](https://www.npmjs.com/package/npm);
* run `npm install` inside `./frontend`;
* run `npm run build` there;
* result files will be saved in `./frontend/public`.
**Note** Running `npm install` will set up precommit hooks into your git repository.
It used to reformat your frontend code using `prettier` and lint with `eslint` and `stylelint` before every commit.
#### Devserver
For local development mode with Hot Reloading use `npm start` instead of `npm run build`.
In this case `webpack` will serve files using `webpack-dev-server` on `localhost:9000`.
By visiting `127.0.0.1:9000/web` you will get a page with main comments widget
communicating with demo server backend running on `https://demo.remark42.com`.
But you will not be able to login with any oauth providers due to security reasons.
You can attach to locally running backend by providing `REMARK_URL` environment variable.
```sh
npx cross-env REMARK_URL=http://127.0.0.1:8080 npm start
```
**Note** If you want to redefine env variables such as `PORT` on your local instance you can add `.env` file
to `./frontend` folder and rewrite variables as you wish. For such functional we use `dotenv`
The best way for start local developer environment:
```sh
cp compose-dev-frontend.yml compose-private-frontend.yml
docker-compose -f compose-private-frontend.yml up --build
cd frontend
npm run dev
```
Developer build running by `webpack-dev-server` supports devtools for [React](https://github.com/facebook/react-devtools) and
[Redux](https://github.com/zalmoxisus/redux-devtools-extension).
## API
### Authorization
* `GET /auth/{provider}/login?from=http://url&site=site_id&session=1` - perform "social" login with one of supported providers and redirect to `url`. Presence of `session` (any non-zero value) change the default cookie expiration and makes them session-only.
* `GET /auth/logout` - logout
```go
type User struct {
Name string `json:"name"`
ID string `json:"id"`
Picture string `json:"picture"`
Admin bool `json:"admin"`
Blocked bool `json:"block"`
Verified bool `json:"verified"`
}
```
_currently supported providers are `google`, `facebook`, `github` and `yandex`_
### Commenting
* `POST /api/v1/comment` - add a comment. _auth required_
```go
type Comment struct {
ID string `json:"id"` // comment ID, read only
ParentID string `json:"pid"` // parent ID
Text string `json:"text"` // comment text, after md processing
Orig string `json:"orig"` // original comment text
User User `json:"user"` // user info, read only
Locator Locator `json:"locator"` // post locator
Score int `json:"score"` // comment score, read only
Vote int `json:"vote"` // vote for the current user, -1/1/0.
Controversy float64 `json:"controversy,omitempty"` // comment controversy, read only
Timestamp time.Time `json:"time"` // time stamp, read only
Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response
Pin bool `json:"pin"` // pinned status, read only
Delete bool `json:"delete"` // delete status, read only
PostTitle string `json:"title"` // post title
}
type Locator struct {
SiteID string `json:"site"` // site id
URL string `json:"url"` // post url
}
type Edit struct {
Timestamp time.Time `json:"time" bson:"time"`
Summary string `json:"summary"`
}
```
* `POST /api/v1/preview` - preview comment in html. Body is `Comment` to render
* `GET /api/v1/find?site=site-id&url=post-url&sort=fld&format=tree|plain` - find all comments for given post
This is the primary call used by UI to show comments for given post. It can return comments in two formats - `plain` and `tree`.
In plain format result will be sorted list of `Comment`. In tree format this is going to be tree-like object with this structure:
```go
type Tree struct {
Nodes []Node `json:"comments"`
Info store.PostInfo `json:"info,omitempty"`
}
type Node struct {
Comment store.Comment `json:"comment"`
Replies []Node `json:"replies,omitempty"`
}
```
Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i.e. `-time`. For `tree` mode sort will be applied to top-level comments only and all replies always sorted by time.
* `PUT /api/v1/comment/{id}?site=site-id&url=post-url` - edit comment, allowed once in `EDIT_TIME` minutes since creation. Body is `EditRequest` json
```go
type EditRequest struct {
Text string `json:"text"` // updated text
Summary string `json:"summary"` // optional, summary of the edit
Delete bool `json:"delete"` // delete flag
}{}
```
* `GET /api/v1/last/{max}?site=site-id&since=ts-msec` - get up to `{max}` last comments, `since` (epoch time, milliseconds) is optional
* `GET /api/v1/id/{id}?site=site-id` - get comment by `comment id`
* `GET /api/v1/comments?site=site-id&user=id&limit=N` - get comment by `user id`, returns `response` object
```go
type response struct {
Comments []store.Comment `json:"comments"`
Count int `json:"count"`
}{}
```
* `GET /api/v1/count?site=site-id&url=post-url` - get comment's count for `{url}`
* `POST /api/v1/count?site=siteID` - get number of comments for posts from post body (list of post IDs)
* `GET /api/v1/list?site=site-id&limit=5&skip=2` - list commented posts, returns array or `PostInfo`, limit=0 will return all posts
```go
type PostInfo struct {
URL string `json:"url"`
Count int `json:"count"`
ReadOnly bool `json:"read_only,omitempty"`
FirstTS time.Time `json:"first_time,omitempty"`
LastTS time.Time `json:"last_time,omitempty"`
}
```
* `GET /api/v1/user` - get user info, _auth required_
* `PUT /api/v1/vote/{id}?site=site-id&url=post-url&vote=1` - vote for comment. `vote`=1 will increase score, -1 decrease. _auth required_
* `GET /api/v1/userdata?site=site-id` - export all user data to gz stream _auth required_
* `POST /api/v1/deleteme?site=site-id` - request deletion of user data. _auth required_
* `GET /api/v1/config?site=site-id` - returns configuration (parameters) for given site
```go
type Config struct {
Version string `json:"version"`
EditDuration int `json:"edit_duration"`
MaxCommentSize int `json:"max_comment_size"`
Admins []string `json:"admins"`
AdminEmail string `json:"admin_email"`
Auth []string `json:"auth_providers"`
LowScore int `json:"low_score"`
CriticalScore int `json:"critical_score"`
PositiveScore bool `json:"positive_score"`
ReadOnlyAge int `json:"readonly_age"`
MaxImageSize int `json:"max_image_size"`
EmojiEnabled bool `json:"emoji_enabled"`
}
```
* `GET /api/v1/info?site=site-idd&url=post-url` - returns `PostInfo` for site and url
### Streaming API
Streaming API provide server-sent events for post updates as well as site update
* `GET /api/v1/stream/info?site=site-idd&url=post-url&since=unix_ts_msec` - returns stream (`event: info`) with `PostInfo` records for the site and url. `since` is optional
* `GET /api/v1/stream/last?site=site-id&since=unix_ts_msec` - returns updates stream (`event: last`) with comments for the site, `since` is optional
<details><summary>response example</summary>
```
data: {"url":"https://radio-t.com/blah1","count":2,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.142872-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":3,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.157709-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":4,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.172991-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":5,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.188429-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":6,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.204742-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":7,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.220692-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":8,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.23817-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":9,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.254669-05:00"}
```
</details>
### RSS feeds
* `GET /api/v1/rss/post?site=site-id&url=post-url` - rss feed for a post
* `GET /api/v1/rss/site?site=site-id` - rss feed for given site
* `GET /api/v1/rss/reply?site=site-id&user=user-id` - rss feed for replies to user's comments
### Images management
* `GET /api/v1/picture/{user}/{id}` - load stored image
* `POST /api/v1/picture` - upload and store image, uses post form with `FormFile("file")`. returns `{"id": user/imgid}` _auth required_
_returned id should be appended to load image url on caller side_
### Email subscription
* `GET /api/v1/email?site=site-id` - get user's email, _auth required_
* `POST /api/v1/email/subscribe?site=site-id&address=user@example.org` - makes confirmation token and sends it to user over email, _auth required_
Trying to subscribe same email second time will return response code `409 Conflict` and explaining error message.
* `POST /api/v1/email/confirm?site=site-id&tkn=token` - uses provided token parameter to set email for the user, _auth required_
Setting email subscribe user for all first-level replies to his messages.
* `DELETE /api/v1/email?site=siteID` - removes user's email, _auth required_
### Admin
* `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`.
* `PUT /api/v1/admin/user/{userid}?site=site-id&block=1&ttl=7d` - block or unblock user with optional ttl (default=permanent)
* `GET api/v1/admin/blocked&site=site-id` - list of blocked user ids
```go
type BlockedUser struct {
ID string `json:"id"`
Name string `json:"name"`
Until time.Time `json:"time"`
}
```
* `GET /api/v1/admin/export?site=site-id&mode=[stream|file]` - export all comments to json stream or gz file.
* `POST /api/v1/admin/import?site=site-id` - import comments from the backup, uses post body.
* `POST /api/v1/admin/import/form?site=site-id` - import comments from the backup, user post form.
* `POST /api/v1/admin/remap?site=site-id` - remap comments to different URLs. Expect list of "from-url new-url" pairs separated by \n.
From-url and new-url parts separated by space. If urls end with asterisk (*) it means matching by prefix. Remap procedure based on
export/import chain so make backup first.
```
http://oldsite.com* https://newsite.com*
http://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1
```
* `GET /api/v1/admin/wait?site=site-id` - wait for completion for any async migration ops (import or remap).
* `PUT /api/v1/admin/pin/{id}?site=site-id&url=post-url&pin=1` - pin or unpin comment.
* `GET /api/v1/admin/user/{userid}?site=site-id` - get user's info.
* `DELETE /api/v1/admin/user/{userid}?site=site-id` - delete all user's comments.
* `PUT /api/v1/admin/readonly?site=site-id&url=post-url&ro=1` - set read-only status
* `PUT /api/v1/admin/verify/{userid}?site=site-id&verified=1` - set verified status
* `GET /api/v1/admin/deleteme?token=token` - process deleteme user's request
_all admin calls require auth and admin privilege_
## Privacy
* Remark42 is trying to be very sensitive to any private or semi-private information.
* Authentication requesting the minimal possible scope from authentication providers. All extra information returned by them dropped immediately and not stored in any form.
* Generally, remark42 keeps user id, username and avatar link only. None of these fields exposed directly - id and name hashed, avatar proxied.
* There is no tracking of any sort.
* Login mechanic uses JWT stored in a cookie (httpOnly, secured). The second cookie (XSRF_TOKEN) is a random id preventing CSRF.
* There is no cross-site login, i.e., user's behavior can't be analyzed across independent sites running remark42.
* There are no third-party analytic services involved.
* User can request all information remark42 knows about and export to gz file.
* Supported complete cleanup of all information related to user's activity.
* Cookie lifespan can be restricted to session-only.
* All potentially sensitive data stored by remark42 hashed and encrypted.
## Technical details
* Data stored in [boltdb](https://github.com/coreos/bbolt) (embedded key/value database) files under `STORE_BOLT_PATH`
* Each site stored in a separate boltbd file.
* In order to migrate/move remark42 to another host boltbd files as well as avatars directory `AVATAR_FS_PATH` should be transferred. Optionally, boltdb can be used to store avatars as well.
* Automatic backup process runs every 24h and exports all content in json-like format to `backup-remark-YYYYMMDD.gz`.
* Authentication implemented with [go-pkgz/auth](https://github.com/go-pkgz/auth) stored in a cookie. It uses HttpOnly, secure cookies.
* All heavy REST calls cached internally in LRU cache limited by `CACHE_MAX_ITEMS` and `CACHE_MAX_SIZE` with [go-pkgz/rest](https://github.com/go-pkgz/rest)
* User's activity throttled globally (up to 1000 simultaneous requests) and limited locally (per user, usually up to 10 req/sec)
* Request timeout set to 60sec
* Admin authentication (`--admin-password` set) allows to hit remark42 API without social login and with admin privileges. Adds basic-auth for username: `admin`, password: `${ADMIN_PASSWD}`.
* User can vote for the comment multiple times but only to change the vote. Double-voting not allowed.
* User can edit comments in 5 mins (configurable) window after creation.
* User ID hashed and prefixed by oauth provider name to avoid collisions and potential abuse.
* All avatars resized and cached locally to prevent rate limiters from oauth providers, part of [go-pkgz/auth](https://github.com/go-pkgz/auth) functionality.
* Images can be proxied (`IMAGE_PROXY_HTTP2HTTPS=true`) to prevent mixed http/https.
* All images can be proxied and saved (`IMAGE_PROXY_CACHE_EXTERNAL=true`) instead of serving from original location. Beware, images which are posted with this parameter enabled will be served from proxy even after it will be disabled.
* Docker build uses [publicly available](https://github.com/umputun/baseimage) base images.
* [A Helm chart for Remark42 on Kubernetes](https://github.com/groundhog2k/helm-charts/tree/master/charts/remark42)
* [django-remark42](https://github.com/andrewp-as-is/django-remark42.py)
+4 -7
View File
@@ -6,13 +6,10 @@ We release patches for security vulnerabilities.
| Version | Supported |
| ------- | ------------------ |
| current | :white_check_mark:
| 1.6.x | :white_check_mark: |
| <1.5.x | :x: |
| current | :white_check_mark: |
| >=1.6.x | :white_check_mark: |
| <1.5.x | :x: |
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to umputun@gmail.com. You will receive a response from us within 48 hours.
If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
Please report (suspected) security vulnerabilities either by using GitHub's [private vulnerability reporting](https://github.com/umputun/remark42/security/advisories/new) (click the "Report a vulnerability" button on the [Security tab](https://github.com/umputun/remark42/security)) or by emailing umputun@gmail.com. You will receive a response within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
+63 -71
View File
@@ -1,78 +1,70 @@
run:
timeout: 5m
output:
format: tab
skip-dirs:
- vendor
linters-settings:
govet:
check-shadowing: true
golint:
min-confidence: 0.1
maligned:
suggest-new: true
goconst:
min-len: 2
min-occurrences: 2
misspell:
locale: US
lll:
line-length: 140
gocritic:
enabled-tags:
- performance
- style
- experimental
disabled-checks:
- wrapperFunc
# TODO: feel free to remove these excludes and fix the code
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain
version: "2"
linters:
default: none
enable:
- megacheck
- golint
- govet
- unconvert
- megacheck
- structcheck
- gas
- gocyclo
- bodyclose
- copyloopvar
- dupl
- misspell
- unparam
- varcheck
- deadcode
- typecheck
- ineffassign
- varcheck
- stylecheck
- gochecknoinits
- scopelint
- gocritic
- gocyclo
- gosec
- govet
- ineffassign
- misspell
- nakedret
- gosimple
- prealloc
fast: false
disable-all: true
issues:
exclude-rules:
- text: "at least one file in a package should have a package comment"
linters:
- stylecheck
- text: "should have a package comment, unless it's in another file for this package"
linters:
- golint
- path: _test\.go
linters:
- gosec
- dupl
exclude-use-default: false
service:
golangci-lint-version: 1.31.x
- revive
- staticcheck
- unconvert
- unparam
- unused
settings:
gosec:
excludes:
- G117 # false positive: struct field name matches "secret" pattern
gocritic:
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
enabled-tags:
- performance
- style
- experimental
govet:
enable:
- shadow
misspell:
locale: US
exclusions:
generated: lax
rules:
- linters:
- staticcheck
text: at least one file in a package should have a package comment
- linters:
- revive
text: 'package-comments: should have a package comment'
- linters:
- revive
text: 'var-naming: avoid meaningless package names'
- linters:
- revive
text: 'var-naming: avoid package names that conflict with Go standard library package names'
- linters:
- dupl
- gosec
path: _test\.go
paths:
- vendor
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+1
View File
@@ -0,0 +1 @@
../site/content/docs/contributing/backend/index.md
+12 -5
View File
@@ -1,15 +1,22 @@
FROM umputun/baseimage:buildgo-latest as build-backend
#ADD . /build/memory_store
#WORKDIR /build/memory_store
FROM umputun/baseimage:buildgo-v1.17.0 AS build-backend
ADD backend /build/backend
WORKDIR /build/backend/_example/memory_store
RUN go build -o /build/bin/memory_store -ldflags "-X main.revision=0.0.0 -s -w"
FROM umputun/baseimage:app-v1.17.0
FROM umputun/baseimage:app-latest
ARG GITHUB_SHA
LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
org.opencontainers.image.description="Remark42 comment engine example JRPC memory store" \
org.opencontainers.image.documentation="https://github.com/umputun/remark42/tree/master/backend/_example/memory_store" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.source="https://github.com/umputun/remark42" \
org.opencontainers.image.title="Remark42 JRPC example memory store" \
org.opencontainers.image.url="https://remark42.com/" \
org.opencontainers.image.revision="${GITHUB_SHA}"
WORKDIR /srv
COPY --from=build-backend /build/bin/memory_store /srv/memory_store
+5 -6
View File
@@ -1,13 +1,12 @@
# sample store implementation
# sample store implementation
`memory_store` illustrates how to make a custom storage plugin for remark42.
`memory_store` illustrates how to make a custom storage plugin for remark42.
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run:
1. `docker-compose -f compose-dev-memstore.yml build`
1. `docker-compose -f compose-dev-memstore.yml up`
1. `docker compose -f compose-dev-memstore.yml build`
1. `docker compose -f compose-dev-memstore.yml up`
As usual, demo site will run on http://127.0.0.1:8080/web/
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package.
In real-life usage `replace github.com/umputun/remark42/backend => ../../` should not be used.
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package. In real-life usage `replace github.com/umputun/remark42/backend => ../../` should not be used.
@@ -7,8 +7,9 @@
package accessor
import (
"fmt"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store/admin"
)
@@ -34,7 +35,8 @@ func NewMemAdminStore(key string) *MemAdmin {
return &MemAdmin{data: map[string]AdminRec{}, key: key}
}
// Key executes find by siteID and returns substructure with secret key
// Key supposed to execute find by siteID and returns substructure with secret key,
// but in this case the shared secret is used for all sites
func (m *MemAdmin) Key(_ string) (key string, err error) {
return m.key, nil
}
@@ -43,7 +45,7 @@ func (m *MemAdmin) Key(_ string) (key string, err error) {
func (m *MemAdmin) Admins(siteID string) (ids []string, err error) {
resp, ok := m.data[siteID]
if !ok {
return nil, errors.Errorf("site %s not found", siteID)
return nil, fmt.Errorf("site %s not found", siteID)
}
log.Printf("[DEBUG] admins for %s, %+v", siteID, resp.IDs)
return resp.IDs, nil
@@ -53,7 +55,7 @@ func (m *MemAdmin) Admins(siteID string) (ids []string, err error) {
func (m *MemAdmin) Email(siteID string) (email string, err error) {
resp, ok := m.data[siteID]
if !ok {
return "", errors.Errorf("site %s not found", siteID)
return "", fmt.Errorf("site %s not found", siteID)
}
return resp.Email, nil
@@ -63,7 +65,7 @@ func (m *MemAdmin) Email(siteID string) (email string, err error) {
func (m *MemAdmin) Enabled(siteID string) (ok bool, err error) {
resp, ok := m.data[siteID]
if !ok {
return false, errors.Errorf("site %s not found", siteID)
return false, fmt.Errorf("site %s not found", siteID)
}
return resp.Enabled, nil
}
@@ -72,7 +74,7 @@ func (m *MemAdmin) Enabled(siteID string) (ok bool, err error) {
func (m *MemAdmin) OnEvent(siteID string, ev admin.EventType) error {
resp, ok := m.data[siteID]
if !ok {
return errors.Errorf("site %s not found", siteID)
return fmt.Errorf("site %s not found", siteID)
}
if ev == admin.EvCreate {
resp.CountCreated++ // not a good idea, just for demo
+76 -68
View File
@@ -7,13 +7,12 @@
package accessor
import (
"log"
"fmt"
"sort"
"sync"
"time"
"github.com/pkg/errors"
log "github.com/go-pkgz/lgr"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
@@ -25,7 +24,7 @@ type MemData struct {
posts map[string][]store.Comment // key is siteID
metaUsers map[string]metaUser // key is userID
metaPosts map[store.Locator]metaPost // key is post's locator
sync.RWMutex
mu sync.RWMutex
}
type metaPost struct {
@@ -58,15 +57,15 @@ func NewMemData() *MemData {
func (m *MemData) Create(comment store.Comment) (commentID string, err error) {
if ro, e := m.Flag(engine.FlagRequest{Flag: engine.ReadOnly, Locator: comment.Locator}); e == nil && ro {
return "", errors.Errorf("post %s is read-only", comment.Locator.URL)
return "", fmt.Errorf("post %s is read-only", comment.Locator.URL)
}
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
comments := m.posts[comment.Locator.SiteID]
for _, c := range comments { // don't allow duplicated IDs
if c.ID == comment.ID {
return "", errors.New("dup key")
return "", fmt.Errorf("dup key")
}
}
comments = append(comments, comment)
@@ -76,8 +75,8 @@ func (m *MemData) Create(comment store.Comment) (commentID string, err error) {
// Find returns all comments for post and sorts results
func (m *MemData) Find(req engine.FindRequest) (comments []store.Comment, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
comments = []store.Comment{}
@@ -132,22 +131,22 @@ func (m *MemData) Find(req engine.FindRequest) (comments []store.Comment, err er
// Get returns comment for locator.URL and commentID string
func (m *MemData) Get(req engine.GetRequest) (comment store.Comment, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
return m.get(req.Locator, req.CommentID)
}
// Update updates comment for locator.URL with mutable part of comment
func (m *MemData) Update(comment store.Comment) error {
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
return m.updateComment(comment)
}
// Count returns number of comments for post or user
func (m *MemData) Count(req engine.FindRequest) (count int, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
switch {
case req.Locator.URL != "": // comment's count for post
@@ -161,14 +160,14 @@ func (m *MemData) Count(req engine.FindRequest) (count int, err error) {
})
return len(comments), nil
default:
return 0, errors.Errorf("invalid count request %+v", req)
return 0, fmt.Errorf("invalid count request %+v", req)
}
}
// Info get post(s) meta info
func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
res = []store.PostInfo{}
if req.Locator.URL != "" { // post info
@@ -176,7 +175,7 @@ func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error)
return c.Locator == req.Locator
})
if len(comments) == 0 {
return nil, errors.New("not found")
return nil, fmt.Errorf("not found")
}
info := store.PostInfo{
URL: req.Locator.URL,
@@ -235,13 +234,13 @@ func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error)
return res, nil
}
return nil, errors.Errorf("invalid info request %+v", req)
return nil, fmt.Errorf("invalid info request %+v", req)
}
// Flag sets and gets flag values
func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
if req.Update == engine.FlagNonSet { // read flag value, no update requested
return m.checkFlag(req), nil
@@ -252,11 +251,11 @@ func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
// ListFlags get list of flagged keys, like blocked & verified user
// works for full locator (post flags) or with userID
func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err error) {
m.RLock()
defer m.RUnlock()
func (m *MemData) ListFlags(req engine.FlagRequest) (res []any, err error) {
m.mu.RLock()
defer m.mu.RUnlock()
res = []interface{}{}
res = []any{}
switch req.Flag {
case engine.Verified:
@@ -268,7 +267,7 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
return res, nil
case engine.Blocked:
log.Printf("%+v", m.metaUsers)
log.Printf("[INFO] metaUsers: %+v", m.metaUsers)
for _, u := range m.metaUsers {
if u.SiteID == req.Locator.SiteID && u.Blocked && u.BlockedUntil.After(time.Now()) {
res = append(res, store.BlockedUser{ID: u.UserID, Until: u.BlockedUntil})
@@ -277,7 +276,7 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
return res, nil
}
return nil, errors.Errorf("flag %s not listable", req.Flag)
return nil, fmt.Errorf("flag %s not listable", req.Flag)
}
// UserDetail sets or gets single detail value, or gets all details fo§r requested site.
@@ -285,42 +284,43 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
// and all site's details listing under the same function (and not to extend engine interface by two separate functions).
func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
switch req.Detail {
case engine.UserEmail:
case engine.UserEmail, engine.UserTelegram:
if req.UserID == "" {
return nil, errors.New("userid cannot be empty in request for single detail")
return nil, fmt.Errorf("userid cannot be empty in request for single detail")
}
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
if req.Update == "" { // read detail value, no update requested
return m.getUserDetail(req)
return m.getUserDetail(req), nil
}
return m.setUserDetail(req)
return m.setUserDetail(req), nil
case engine.AllUserDetails:
// list of all details returned in case request is a read request
// (Update is not set) and does not have UserID or Detail set
if req.Update == "" && req.UserID == "" { // read list of all details
m.Lock()
defer m.Unlock()
return m.listDetails(req.Locator)
m.mu.Lock()
defer m.mu.Unlock()
return m.listDetails(req.Locator), nil
}
return nil, errors.New("unsupported request with userdetail all")
return nil, fmt.Errorf("unsupported request with userdetail all")
default:
return nil, errors.Errorf("unsupported detail %q", req.Detail)
return nil, fmt.Errorf("unsupported detail %q", req.Detail)
}
}
// Delete post(s), user, comment, user details, or everything
func (m *MemData) Delete(req engine.DeleteRequest) error {
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
switch {
case req.UserDetail != "": // delete user detail
return m.deleteUserDetail(req.Locator, req.UserID, req.UserDetail)
m.deleteUserDetail(req.Locator, req.UserID, req.UserDetail)
return nil
case req.Locator.URL != "" && req.CommentID != "" && req.UserDetail == "": // delete comment
return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode)
@@ -333,17 +333,18 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
return e
}
}
return m.deleteUserDetail(req.Locator, req.UserID, engine.AllUserDetails)
m.deleteUserDetail(req.Locator, req.UserID, engine.AllUserDetails)
return nil
case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "" && req.UserDetail == "": // delete site
if _, ok := m.posts[req.Locator.SiteID]; !ok {
return errors.New("not found")
return fmt.Errorf("not found")
}
m.posts[req.Locator.SiteID] = []store.Comment{}
return nil
}
return errors.Errorf("invalid delete request %+v", req)
return fmt.Errorf("invalid delete request %+v", req)
}
func (m *MemData) deleteComment(loc store.Locator, id string, mode store.DeleteMode) error {
@@ -352,7 +353,7 @@ func (m *MemData) deleteComment(loc store.Locator, id string, mode store.DeleteM
return c.Locator == loc && c.ID == id
})
if len(comments) == 0 {
return errors.New("not found")
return fmt.Errorf("not found")
}
comments[0].SetDeleted(mode)
@@ -390,10 +391,7 @@ func (m *MemData) checkFlag(req engine.FlagRequest) (val bool) {
func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
status := false
if req.Update == engine.FlagTrue {
status = true
}
status := req.Update == engine.FlagTrue
switch req.Flag {
@@ -430,32 +428,37 @@ func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
info.ReadOnly = status
m.metaPosts[req.Locator] = info
}
return status, errors.Wrapf(err, "failed to set flag %+v", req)
if err != nil {
return false, fmt.Errorf("failed to set flag %+v: %w", req, err)
}
return status, nil
}
// getUserDetail returns UserDetailEntry with requested userDetail (omitting other details)
// as an only element of the slice.
func (m *MemData) getUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
func (m *MemData) getUserDetail(req engine.UserDetailRequest) []engine.UserDetailEntry {
if meta, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID {
return []engine.UserDetailEntry{}, nil
return []engine.UserDetailEntry{}
}
switch req.Detail {
case engine.UserEmail:
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}, nil
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}
case engine.UserTelegram:
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}
}
}
return []engine.UserDetailEntry{}, nil
return []engine.UserDetailEntry{}
}
// setUserDetail sets requested userDetail, returning complete updated UserDetailEntry as an onlyIps
// element of the slice in case of success
func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
func (m *MemData) setUserDetail(req engine.UserDetailRequest) []engine.UserDetailEntry {
var entry metaUser
if meta, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID {
return []engine.UserDetailEntry{}, nil
return []engine.UserDetailEntry{}
}
entry = meta
}
@@ -472,43 +475,49 @@ func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
case engine.UserEmail:
entry.Details.Email = req.Update
m.metaUsers[req.UserID] = entry
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}, nil
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}
case engine.UserTelegram:
entry.Details.Telegram = req.Update
m.metaUsers[req.UserID] = entry
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}
}
return []engine.UserDetailEntry{}, nil
return []engine.UserDetailEntry{}
}
// listDetails lists all available users details for given siteID
func (m *MemData) listDetails(loc store.Locator) ([]engine.UserDetailEntry, error) {
func (m *MemData) listDetails(loc store.Locator) []engine.UserDetailEntry {
var res []engine.UserDetailEntry
for _, u := range m.metaUsers {
if u.SiteID == loc.SiteID {
res = append(res, u.Details)
}
}
return res, nil
return res
}
// deleteUserDetail deletes requested UserDetail or whole UserDetailEntry,
// deletion of the absent entry doesn't produce error.
// Trying to delete user with wrong siteID doesn't to anything and doesn't produce error.
func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDetail engine.UserDetail) error {
func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDetail engine.UserDetail) {
var entry metaUser
if meta, ok := m.metaUsers[userID]; ok {
if meta.SiteID != locator.SiteID {
return nil
return
}
entry = meta
}
if entry == (metaUser{}) || entry.Details == (engine.UserDetailEntry{}) {
// absent entry means that we should not do anything
return nil
return
}
switch userDetail {
case engine.UserEmail:
entry.Details.Email = ""
case engine.UserTelegram:
entry.Details.Telegram = ""
case engine.AllUserDetails:
entry.Details = engine.UserDetailEntry{UserID: userID}
}
@@ -519,7 +528,6 @@ func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDet
}
m.metaUsers[userID] = entry
return nil
}
func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error) {
@@ -527,7 +535,7 @@ func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error
return c.Locator == loc && c.ID == commentID
})
if len(comments) == 0 {
return store.Comment{}, errors.New("not found")
return store.Comment{}, fmt.Errorf("not found")
}
return comments[0], nil
}
@@ -549,7 +557,7 @@ func (m *MemData) updateComment(comment store.Comment) error {
m.posts[comment.Locator.SiteID] = comments
return nil
}
return errors.New("not found")
return fmt.Errorf("not found")
}
func (m *MemData) match(comments []store.Comment, fn func(c store.Comment) bool) (res []store.Comment) {
@@ -10,6 +10,7 @@ import (
"fmt"
"sort"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
@@ -198,7 +199,7 @@ func TestMemData_FindForUserPagination(t *testing.T) {
}
// write 200 comments
for i := 0; i < 200; i++ {
for i := range 200 {
c.ID = fmt.Sprintf("idd-%d", i)
c.Text = fmt.Sprintf("text #%d", i)
c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.Local)
@@ -286,7 +287,7 @@ func TestMemData_CountUser(t *testing.T) {
func TestMemData_InfoPost(t *testing.T) {
b := prepMem(t)
ts := func(min int) time.Time { return time.Date(2017, 12, 20, 15, 18, min, 0, time.Local).In(time.UTC) }
ts := func(minute int) time.Time { return time.Date(2017, 12, 20, 15, 18, minute, 0, time.Local).In(time.UTC) }
// add one more for https://radio-t.com/2
comment := store.Comment{
@@ -484,7 +485,7 @@ func TestMemData_FlagVerified(t *testing.T) {
func TestMemData_FlagListVerified(t *testing.T) {
b := prepMem(t)
toIDs := func(inp []interface{}) (res []string) {
toIDs := func(inp []any) (res []string) {
res = make([]string, len(inp))
for i, v := range inp {
vv, ok := v.(string)
@@ -521,51 +522,52 @@ func TestMemData_FlagListVerified(t *testing.T) {
}
func TestMemData_FlagListBlocked(t *testing.T) {
b := prepMem(t)
setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
TTL: ttl}
_, err := b.Flag(req)
return err
}
toBlocked := func(inp []interface{}) (res []store.BlockedUser) {
res = make([]store.BlockedUser, len(inp))
for i, v := range inp {
vv, ok := v.(store.BlockedUser)
require.True(t, ok)
res[i] = vv
synctest.Test(t, func(t *testing.T) {
b := prepMem(t)
setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
TTL: ttl}
_, err := b.Flag(req)
return err
}
return res
}
assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
toBlocked := func(inp []any) (res []store.BlockedUser) {
res = make([]store.BlockedUser, len(inp))
for i, v := range inp {
vv, ok := v.(store.BlockedUser)
require.True(t, ok)
res[i] = vv
}
return res
}
assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
blockedList := toBlocked(vv)
var blockedIds = make([]string, len(blockedList))
for i, x := range blockedList {
blockedIds[i] = x.ID
}
require.Equal(t, 2, len(blockedList), b.metaUsers)
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIds)
t.Logf("%+v", blockedList)
vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
// check block expiration
time.Sleep(50 * time.Millisecond)
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
blockedList = toBlocked(vv)
require.Equal(t, 1, len(blockedList))
assert.Equal(t, "user1", blockedList[0].ID)
blockedList := toBlocked(vv)
var blockedIDs = make([]string, len(blockedList))
for i, x := range blockedList {
blockedIDs[i] = x.ID
}
require.Equal(t, 2, len(blockedList), b.metaUsers)
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIDs)
t.Logf("%+v", blockedList)
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
assert.NoError(t, err)
assert.Equal(t, 0, len(vv))
// check block expiration
time.Sleep(50 * time.Millisecond)
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
blockedList = toBlocked(vv)
require.Equal(t, 1, len(blockedList))
assert.Equal(t, "user1", blockedList[0].ID)
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
assert.NoError(t, err)
assert.Equal(t, 0, len(vv))
})
}
func TestMemData_DeleteComment(t *testing.T) {
@@ -624,6 +626,7 @@ func TestMemData_DeleteComment(t *testing.T) {
func TestMemData_Close(t *testing.T) {
b := prepMem(t)
assert.NoError(t, b.Close())
assert.NoError(t, b.Close(), "second call should not result in panic or errors")
}
func TestMemData_DeleteHard(t *testing.T) {
@@ -658,12 +661,40 @@ func TestMemData_DeleteAll(t *testing.T) {
assert.Equal(t, 0, len(comments), "nothing left")
}
func TestMemData_UserDetailAll(t *testing.T) {
b := prepMem(t)
val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails})
require.NoError(t, err)
require.Nil(t, val)
}
func TestMemData_UserDetailErrors(t *testing.T) {
b := prepMem(t)
val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.UserEmail, Update: "value1"})
require.EqualError(t, err, "userid cannot be empty in request for single detail")
require.Nil(t, val)
val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails, Update: "value1"})
require.EqualError(t, err, "unsupported request with userdetail all")
require.Nil(t, val)
val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: "bad"})
require.EqualError(t, err, "unsupported detail \"bad\"")
require.Nil(t, val)
}
func TestMemData_DeleteUserDetail(t *testing.T) {
var (
createUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"}
readUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail}
emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}}
emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}}
createEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"}
readEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail}
createTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram, Update: "value1"}
readTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram}
emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}}
emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}}
telegramSet = []engine.UserDetailEntry{{UserID: "user1", Telegram: "value1"}}
telegramUnset = []engine.UserDetailEntry{{UserID: "user1", Telegram: ""}}
)
b := prepMem(t)
@@ -674,15 +705,21 @@ func TestMemData_DeleteUserDetail(t *testing.T) {
expected []engine.UserDetailEntry
}{
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: createUser, expected: emailSet},
detailReq: createEmailUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: readUser, expected: emailSet},
detailReq: readEmailUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: readUser, expected: emailUnset},
detailReq: readEmailUser, expected: emailUnset},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram},
detailReq: createTelegramUser, expected: telegramSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserTelegram},
detailReq: readTelegramUser, expected: telegramSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram},
detailReq: readTelegramUser, expected: telegramUnset},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
detailReq: createUser, expected: emailSet},
detailReq: createEmailUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
detailReq: readUser, expected: emailUnset},
detailReq: readEmailUser, expected: emailUnset},
}
for i, x := range testData {
+40 -18
View File
@@ -8,11 +8,11 @@ package accessor
import (
"context"
"fmt"
"sync"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store/image"
)
@@ -22,7 +22,7 @@ type MemImage struct {
imagesStaging map[string][]byte
images map[string][]byte
insertTime map[string]time.Time
sync.RWMutex
mu sync.RWMutex
}
// NewMemImageStore makes admin Store in memory.
@@ -37,40 +37,62 @@ func NewMemImageStore() *MemImage {
// Save stores image with passed id to staging
func (m *MemImage) Save(id string, img []byte) error {
m.Lock()
m.mu.Lock()
m.imagesStaging[id] = img
m.insertTime[id] = time.Now()
m.Unlock()
m.mu.Unlock()
return nil
}
// ResetCleanupTimer resets cleanup timer for the image
func (m *MemImage) ResetCleanupTimer(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.insertTime[id]; ok {
m.insertTime[id] = time.Now()
return nil
}
return fmt.Errorf("image %s not found", id)
}
// Load image by ID
func (m *MemImage) Load(id string) ([]byte, error) {
m.RLock()
m.mu.RLock()
img, ok := m.images[id]
if !ok {
img, ok = m.imagesStaging[id]
}
m.RUnlock()
m.mu.RUnlock()
if !ok {
return nil, errors.Errorf("image %s not found", id)
return nil, fmt.Errorf("image %s not found", id)
}
return img, nil
}
// Delete image by ID
func (m *MemImage) Delete(id string) error {
m.mu.Lock()
// delete key from permanent and staging storage
delete(m.images, id)
delete(m.insertTime, id)
delete(m.imagesStaging, id)
m.mu.Unlock()
return nil
}
// Commit moves image from staging to permanent
func (m *MemImage) Commit(id string) error {
m.RLock()
m.mu.RLock()
img, ok := m.imagesStaging[id]
m.RUnlock()
m.mu.RUnlock()
if !ok {
return errors.Errorf("failed to commit %s, not found in staging", id)
return fmt.Errorf("failed to commit %s, not found in staging", id)
}
m.Lock()
m.mu.Lock()
m.images[id] = img
m.Unlock()
m.mu.Unlock()
return nil
}
@@ -79,7 +101,7 @@ func (m *MemImage) Commit(id string) error {
func (m *MemImage) Cleanup(_ context.Context, ttl time.Duration) error {
var idsToRemove []string
m.RLock()
m.mu.RLock()
for id, t := range m.insertTime {
age := time.Since(t)
if age > ttl {
@@ -87,27 +109,27 @@ func (m *MemImage) Cleanup(_ context.Context, ttl time.Duration) error {
idsToRemove = append(idsToRemove, id)
}
}
m.RUnlock()
m.mu.RUnlock()
m.Lock()
m.mu.Lock()
for _, id := range idsToRemove {
delete(m.insertTime, id)
delete(m.imagesStaging, id)
}
m.Unlock()
m.mu.Unlock()
return nil
}
// Info returns meta information about storage
func (m *MemImage) Info() (image.StoreInfo, error) {
var ts time.Time
m.RLock()
m.mu.RLock()
for _, t := range m.insertTime {
if ts.IsZero() || t.Before(ts) {
ts = t
}
}
m.RUnlock()
m.mu.RUnlock()
return image.StoreInfo{FirstStagingImageTS: ts}, nil
}
@@ -10,7 +10,6 @@ import (
"context"
"encoding/base64"
"io"
"io/ioutil"
"strings"
"testing"
"time"
@@ -19,7 +18,7 @@ import (
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
const rawGopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
@@ -39,11 +38,13 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNG() io.Reader {
return base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawGopher))
}
func TestMemImage_LoadAfterSave(t *testing.T) {
svc := NewMemImageStore()
gopher, err := ioutil.ReadAll(gopherPNG())
gopher, err := io.ReadAll(gopherPNG())
assert.NoError(t, err)
img, err := svc.Load("test_id")
@@ -58,6 +59,9 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, gopher, img)
err = svc.ResetCleanupTimer(id)
assert.NoError(t, err)
err = svc.Commit(id)
assert.NoError(t, err)
@@ -69,6 +73,26 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
assert.Equal(t, gopher, img)
}
func TestMemImage_LoadAfterDelete(t *testing.T) {
svc := NewMemImageStore()
gopher, err := io.ReadAll(gopherPNG())
assert.NoError(t, err)
id := "test_img"
err = svc.Save(id, gopher)
assert.NoError(t, err)
err = svc.Delete(id)
assert.NoError(t, err)
img, err := svc.Load(id)
assert.EqualError(t, err, "image test_img not found")
assert.Empty(t, img)
err = svc.ResetCleanupTimer(id)
assert.EqualError(t, err, "image test_img not found")
}
func TestMemImage_CommitFail(t *testing.T) {
svc := NewMemImageStore()
err := svc.Commit("test_id")
@@ -83,7 +107,7 @@ func TestMemImage_Cleanup(t *testing.T) {
func TestMemImage_Info(t *testing.T) {
svc := NewMemImageStore()
gopher, err := ioutil.ReadAll(gopherPNG())
gopher, err := io.ReadAll(gopherPNG())
assert.NoError(t, err)
// get info on empty storage, should be zero
@@ -1,10 +1,9 @@
# compose file demonstrating custom storage use. The memory_store (see backend/_example/memory_store) starts
# in a separate container and remark42 communicates to mem_store.r42 via STORE_RPC_API url
version: '2'
version: "2"
services:
remark42:
build:
context: ../../..
@@ -12,7 +11,7 @@ services:
args:
- SKIP_BACKEND_TEST=true
- SKIP_FRONTEND_TEST=true
image: umputun/remark42:dev
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-dev"
hostname: "remark42-dev"
restart: always
@@ -30,7 +29,6 @@ services:
environment:
- REMARK_URL=http://127.0.0.1:8080
- SECRET=123456
- BACKUP_PATH=/srv/var/backup
- DEBUG=true
- EMOJI=true
- AUTH_ANON=true
+28 -14
View File
@@ -1,20 +1,34 @@
module github.com/umputun/remark42/memory_store
go 1.14
go 1.25.0
require (
github.com/go-pkgz/auth v1.15.0 // indirect
github.com/go-pkgz/expirable-cache v0.0.3 // indirect
github.com/go-pkgz/jrpc v0.2.0
github.com/go-pkgz/lcw v0.8.1 // indirect
github.com/go-pkgz/lgr v0.10.4
github.com/go-pkgz/repeater v1.1.3 // indirect
github.com/go-pkgz/rest v1.9.2 // indirect
github.com/go-pkgz/syncs v1.1.1 // indirect
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.0
github.com/umputun/go-flags v1.5.1
github.com/umputun/remark42/backend v1.7.1
github.com/go-pkgz/jrpc v0.4.2
github.com/go-pkgz/lgr v0.12.4
github.com/jessevdk/go-flags v1.6.1
github.com/stretchr/testify v1.12.1
github.com/umputun/remark42/backend v1.1000.0
)
replace github.com/umputun/remark42/backend => ../../
require (
github.com/Depado/bfchroma/v2 v2.0.0 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/go-pkgz/rest v1.24.0 // indirect
github.com/go-pkgz/routegroup v1.6.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
replace github.com/umputun/remark42/backend v1.1000.0 => ../../
+48 -352
View File
@@ -1,356 +1,52 @@
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Depado/bfchroma v1.2.0 h1:NyYPFVhWvq8S2ts6Ok4kwXVE3TEO5fof+9ZOKbBJQUo=
github.com/Depado/bfchroma v1.2.0/go.mod h1:U3RJUYwWVJrZRaJQyfS+wuxBApSTR/BC37PhAI+Ydps=
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.6.0/go.mod h1:MmozekIi2rfQSzDcdEZ2BoJ9Pxs/7uc2Y4Boh+hIeZo=
github.com/alecthomas/chroma v0.7.2 h1:B76NU/zbQYIUhUowbi4fmvREmDUJLsUzKWTZmQd3ABY=
github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 h1:GDQdwm/gAcJcLAKQQZGOJ4knlw+7rfEQQcmwTbt4p5E=
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.11.4/go.mod h1:VL3UDEfAH59bSa7MuHMuFToxkqyHh69s/WUbYlOAuyg=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPIk=
github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48=
github.com/Depado/bfchroma/v2 v2.0.0 h1:IRpN9BPkNwEpR6w1ectIcNWOuhDSLx+8f1pn83fzxx8=
github.com/Depado/bfchroma/v2 v2.0.0/go.mod h1:wFwW/Pw8Tnd0irzgO9Zxtxgzp3aPS8qBWlyadxujxmw=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
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/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
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/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
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/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0=
github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/didip/tollbooth/v6 v6.0.1 h1:QvLvRpB1G2bzKvkRze0muMUBlGN9H1z7tJ4DH4ypWOU=
github.com/didip/tollbooth/v6 v6.0.1/go.mod h1:j2pKs+JQ5PvU/K4jFnrnwntrmfUbYLJE5oSdxR37FD0=
github.com/didip/tollbooth/v6 v6.1.0 h1:ZS2fNa9JhFdRSJCj3+V12VfuUifYrGB4Z0jSwXmKMeE=
github.com/didip/tollbooth/v6 v6.1.0/go.mod h1:xjcse6CTHCLuOkzsWrEgdy9WPJFv+p/x6v+MyfP+O9s=
github.com/didip/tollbooth_chi v0.0.0-20200524181329-8b84cd7183d9 h1:gTh8fKuI/yLqQtZEPlDX3ZGsiTPZIe0ADHsxXSbwO1I=
github.com/didip/tollbooth_chi v0.0.0-20200524181329-8b84cd7183d9/go.mod h1:YWyIfq3y4ArRfWZ9XksmuusP+7Mad+T0iFZ0kv0XG/M=
github.com/didip/tollbooth_chi v0.0.0-20200828173446-a7173453ea21 h1:x7YpwKSBIBcKe9I3aTNOqgSyJ6QKDdtOxnEkxBTsi9w=
github.com/didip/tollbooth_chi v0.0.0-20200828173446-a7173453ea21/go.mod h1:0ZVa6kSzS011nfTC1rELyxK4tjVf6vqBnOv7oY2KlsA=
github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.0.2/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.1.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/cors v1.2.0/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
github.com/go-pkgz/auth v0.11.0/go.mod h1:NzVqlTW0E9JXVdAaWRq81XZjICgHnNaNdUfE3CbS2T4=
github.com/go-pkgz/auth v1.14.0/go.mod h1:1GVd61pXZcuJ0ZnOUdCTY08V8SreO7MJtsvEd5/WEWA=
github.com/go-pkgz/auth v1.15.0 h1:77z+YdcxHkRcjJSQc1SBtqIrZbp3p8Zo72ZRR3QKsLk=
github.com/go-pkgz/auth v1.15.0/go.mod h1:1HTdNEBMSFRCXoJLLjgCRs8t/gt2TQUaqf6wgYRvsTo=
github.com/go-pkgz/expirable-cache v0.0.3 h1:rTh6qNPp78z0bQE6HDhXBHUwqnV9i09Vm6dksJLXQDc=
github.com/go-pkgz/expirable-cache v0.0.3/go.mod h1:+IauqN00R2FqNRLCLA+X5YljQJrwB179PfiAoMPlTlQ=
github.com/go-pkgz/jrpc v0.2.0 h1:CLy/eZyekjraVrxZV18N2R1mYLMJ/nWrgdfyIOGPY/E=
github.com/go-pkgz/jrpc v0.2.0/go.mod h1:wd8vtQ4CgtCnuqua6x2b1SKIgv0VSOh5Dn0uUITbiUE=
github.com/go-pkgz/lcw v0.7.1/go.mod h1:3P6g9QrJsDePXEMe42ywO+tW08L17tBJGwIDdI7lZ6g=
github.com/go-pkgz/lcw v0.8.1 h1:Bpt2yYTE1J8hIhz8tjdm1WPOgH13eo5iTNsXyop7cMQ=
github.com/go-pkgz/lcw v0.8.1/go.mod h1:Xw0/ZfApATgbjVPYRZO4XHdWyxAjErDWDWJ7TLlw1Vc=
github.com/go-pkgz/lgr v0.7.0 h1:S/AAPwt/RE9a5mNJskA7dGVp+Dq6SMIW6LYjG3ITxY8=
github.com/go-pkgz/lgr v0.7.0/go.mod h1:yMgxU+GobMRJgIEbSzDKy/67W18S7qmGx/7BVL5AB8Q=
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw=
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0=
github.com/go-pkgz/repeater v1.1.3 h1:q6+JQF14ESSy28Dd7F+wRelY4F+41HJ0LEy/szNnMiE=
github.com/go-pkgz/repeater v1.1.3/go.mod h1:hVTavuO5x3Gxnu8zW7d6sQBfAneKV8X2FjU48kGfpKw=
github.com/go-pkgz/rest v1.5.0 h1:C8SxXcXza4GiUUAn/95iCkvoIrGbS30qpwK19iqlrWQ=
github.com/go-pkgz/rest v1.5.0/go.mod h1:nQaM3RhSTUAmbBZWY4hfe4buyeC9VckvhoCktiQXJxI=
github.com/go-pkgz/rest v1.6.0/go.mod h1:FKpgK5FgSqREG323OIU/JpIc0xA7dqay9BmK7LZXTQE=
github.com/go-pkgz/rest v1.9.2 h1:RyBBRXBYY6eBgTW3UGYOyT4VQPDiBBFh/tesELWsryQ=
github.com/go-pkgz/rest v1.9.2/go.mod h1:wZ/dGipZUaF9to0vIQl7PwDHgWQDB0jsrFg1xnAKLDw=
github.com/go-pkgz/syncs v1.1.1 h1:jWN+y6FS/Xe+8z4l3QMbSnODGyaxDHGojIS+wyKIjxg=
github.com/go-pkgz/syncs v1.1.1/go.mod h1:bt9lxWRRJ9vOCMGc8Big8ttjYHLKP88ofj1y38UlaHE=
github.com/go-redis/redis/v7 v7.2.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-redis/redis/v7 v7.4.0 h1:7obg6wUoj05T0EpY0o8B59S9w5yeMWql7sw2kwNW1x4=
github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0=
github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY=
github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg=
github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs=
github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk=
github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28=
github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo=
github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk=
github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw=
github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360=
github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg=
github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE=
github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8=
github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.7.1-0.20190322064113-39e2c31b7ca3/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.5 h1:U+CaK85mrNNb4k8BNOfgJtJ/gr6kswUCFj6miSzVC6M=
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kyokomi/emoji v2.2.1+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/microcosm-cc/bluemonday v1.0.9 h1:dpCwruVKoyrULicJwhuY76jB+nIxRVKv/e248Vx/BXg=
github.com/microcosm-cc/bluemonday v1.0.9/go.mod h1:B2riunDr9benLHghZB7hjIgdwSUzzs0pjCxFrWYEZFU=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4UMlGaDa1UTA0sfKgvF0hQZzTYX8ktjiDc=
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/go-pkgz/jrpc v0.4.2 h1:gY5mmxp9/dFd1WsHybVZILQpF11YNWWS3Ga+Pc5aIAU=
github.com/go-pkgz/jrpc v0.4.2/go.mod h1:ZtnMpIXYmwXh6W44XO2lE5Lh5J+6KeeMIvw+vF9xXRQ=
github.com/go-pkgz/lgr v0.12.4 h1:lDeQ4BR28ldXrKau6BOjq7A8nHzcXz+MF4xUfV4l1Ok=
github.com/go-pkgz/lgr v0.12.4/go.mod h1:Lw6DkNRnCPyX07mqkiUK/p+eA1opq4GKkWfWia64RA8=
github.com/go-pkgz/rest v1.24.0 h1:GAUCgx7U8xCOC2OynLjhCRMhtnMQH4d1mTdKpQyX2yI=
github.com/go-pkgz/rest v1.24.0/go.mod h1:dl3EWiuFB4hRTo2Sknj6UrQGFRAYvANK6/NyW8qQPxc=
github.com/go-pkgz/routegroup v1.6.1 h1:6I/0LabazpZsHAI+jYPeyH/KU2cvZF0bFylUScMNi+Q=
github.com/go-pkgz/routegroup v1.6.1/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
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/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
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/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/slack-go/slack v0.8.2/go.mod h1:FGqNzJBmxIsZURAxh2a8D21AnOVvvXZvGligs4npPUM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.1.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
github.com/umputun/go-flags v1.5.1 h1:vRauoXV3Ultt1HrxivSxowbintgZLJE+EcBy5ta3/mY=
github.com/umputun/go-flags v1.5.1/go.mod h1:nTbvsO/hKqe7Utri/NoyN18GR3+EWf+9RrmsdwdhrEc=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc h1:n+nNi93yXLkJvKwXNP9d55HC7lGK4H/SRcwB5IaUZLo=
github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.mongodb.org/mongo-driver v1.3.2/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE=
go.mongodb.org/mongo-driver v1.4.4 h1:bsPHfODES+/yx2PCWzUYMH8xj6PVniPI8DQrsJuSXSs=
go.mongodb.org/mongo-driver v1.4.4/go.mod h1:WcMNYLx/IlOxLe6JRJiv2uXuCz6zBLndR4SoGjYphSc=
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 h1:eDrdRpKgkcCqKZQwyZRyeFZgfqt37SL7Kv3tok06cKE=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 h1:0PC75Fz/kyMGhL0e1QnypqK2kQMqKt9csD1GnMJR+Zk=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190412183630-56d357773e84/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-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/oauth2.v3 v3.12.0 h1:yOffAPoolH/i2JxwmC+pgtnY3362iPahsDpLXfDFvNg=
gopkg.in/oauth2.v3 v3.12.0/go.mod h1:XEYgKqWX095YiPT+Aw5y3tCn+7/FMnlTFKrupgSiJ3I=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+8 -10
View File
@@ -12,7 +12,7 @@ import (
"github.com/go-pkgz/jrpc"
log "github.com/go-pkgz/lgr"
"github.com/umputun/go-flags"
"github.com/jessevdk/go-flags"
"github.com/umputun/remark42/memory_store/accessor"
"github.com/umputun/remark42/memory_store/server"
@@ -43,16 +43,14 @@ func main() {
adminStore := accessor.NewMemAdminStore(opts.Secret)
imgStore := accessor.NewMemImageStore()
rpcServer := jrpc.Server{
API: opts.API,
AuthUser: opts.AuthUser,
AuthPasswd: opts.AuthPasswd,
Version: revision,
AppName: "remark42-memory",
Logger: log.Default(),
}
rpcServer := jrpc.NewServer(
opts.API,
jrpc.Auth(opts.AuthUser, opts.AuthPasswd),
jrpc.WithSignature("remark42-memory", "umputun", revision),
jrpc.WithLogger(log.Default()),
)
srv := server.NewRPC(dataStore, adminStore, imgStore, &rpcServer)
srv := server.NewRPC(dataStore, adminStore, imgStore, rpcServer)
admRec := accessor.AdminRec{
SiteID: "remark",
@@ -73,7 +73,7 @@ func (s *RPC) admEnabledHndl(id uint64, params json.RawMessage) (rr jrpc.Respons
// onEvent returns nothing, callback to OnEvent
func (s *RPC) admEventHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var siteID string
var ps []interface{}
var ps []any
if err := json.Unmarshal(params, &ps); err != nil {
return jrpc.Response{Error: err.Error()}
}
@@ -198,25 +198,62 @@ func TestRPC_listFlagsHndl(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "123456", id)
flagReq := engine.FlagRequest{
// verify user
verifyFlagReq := engine.FlagRequest{
Flag: engine.Verified,
UserID: "u1",
Locator: store.Locator{
SiteID: "test-site",
},
}
flags, err := re.ListFlags(flagReq)
flags, err := re.ListFlags(verifyFlagReq)
require.NoError(t, err)
assert.Equal(t, []interface{}{}, flags)
assert.Empty(t, flags)
flagReq.Update = engine.FlagTrue
status, err := re.Flag(flagReq)
verifyFlagReq.Update = engine.FlagTrue
status, err := re.Flag(verifyFlagReq)
require.NoError(t, err)
assert.Equal(t, true, status)
flags, err = re.ListFlags(flagReq)
flags, err = re.ListFlags(verifyFlagReq)
require.NoError(t, err)
assert.Equal(t, []interface{}{"u1"}, flags)
assert.Equal(t, []any{"u1"}, flags)
verifiedUsers := make([]string, 0, len(flags))
for _, v := range flags {
verifiedUsers = append(verifiedUsers, v.(string))
}
assert.Equal(t, []string{"u1"}, verifiedUsers)
// block user
blockFlagReq := engine.FlagRequest{
Flag: engine.Blocked,
UserID: "u1",
Locator: store.Locator{
SiteID: "test-site",
},
TTL: time.Hour,
}
flags, err = re.ListFlags(blockFlagReq)
require.NoError(t, err)
assert.Empty(t, flags)
blockFlagReq.Update = engine.FlagTrue
status, err = re.Flag(blockFlagReq)
require.NoError(t, err)
assert.Equal(t, true, status)
flags, err = re.ListFlags(blockFlagReq)
require.NoError(t, err)
assert.NotEmpty(t, flags)
blockedUsers := make([]store.BlockedUser, 0, len(flags))
for _, v := range flags {
blockedUsers = append(blockedUsers, v.(store.BlockedUser))
}
require.Equal(t, 1, len(blockedUsers))
blockedUserInfo := blockedUsers[0]
assert.Equal(t, "u1", blockedUserInfo.ID)
assert.True(t, blockedUserInfo.Until.After(time.Now().Add(time.Minute*59)), "blocked duration is more than 59m away")
assert.True(t, blockedUserInfo.Until.Before(time.Now().Add(time.Minute*61)), "blocked duration is less than 61m away")
}
func TestRPC_userDetailHndl(t *testing.T) {
@@ -301,6 +338,6 @@ func TestRPC_closeHndl(t *testing.T) {
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
err := re.Close()
assert.NoError(t, err)
assert.NoError(t, re.Close())
assert.NoError(t, re.Close(), "second call should not result in panic or errors")
}
@@ -28,6 +28,15 @@ func (s *RPC) imgSaveWithIDHndl(id uint64, params json.RawMessage) (rr jrpc.Resp
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgResetClnTimerHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.img.ResetCleanupTimer(fileID)
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
@@ -37,6 +46,16 @@ func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response)
return jrpc.EncodeResponse(id, value, err)
}
func (s *RPC) imgDeleteHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.img.Delete(fileID)
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
@@ -11,7 +11,6 @@ import (
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"testing"
@@ -46,7 +45,7 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNGBytes() []byte {
img, _ := ioutil.ReadAll(gopherPNG())
img, _ := io.ReadAll(gopherPNG())
return img
}
@@ -116,7 +115,24 @@ func TestRPC_imgCleanupHndl(t *testing.T) {
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
// cleanup
// age the image past the ttl used below, so the reset that follows is what keeps it on
// staging rather than the image simply being young
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)
assert.NoError(t, err)
// cleanup, should not affect the new image
err = ri.Cleanup(context.TODO(), stagingTTL)
assert.NoError(t, err)
// load after cleanup should succeed
_, err = ri.Load(id)
assert.NoError(t, err, "image is still on staging because it's cleanup timer was reset")
// cleanup with short TTL, should remove the image from staging
err = ri.Cleanup(context.TODO(), time.Nanosecond)
assert.NoError(t, err)
@@ -145,4 +161,9 @@ func TestRPC_imgInfoHndl(t *testing.T) {
info, err = ri.Info()
assert.NoError(t, err)
assert.False(t, info.FirstStagingImageTS.IsZero())
err = ri.Delete("test_img")
assert.NoError(t, err)
_, err = ri.Load("test_img")
assert.EqualError(t, err, "image test_img not found")
}
+7 -5
View File
@@ -57,10 +57,12 @@ func (s *RPC) addHandlers() {
// image store handlers
s.Group("image", jrpc.HandlersGroup{
"save_with_id": s.imgSaveWithIDHndl,
"load": s.imgLoadHndl,
"commit": s.imgCommitHndl,
"cleanup": s.imgCleanupHndl,
"info": s.imgInfoHndl,
"save_with_id": s.imgSaveWithIDHndl,
"reset_cleanup_timer": s.imgResetClnTimerHndl,
"load": s.imgLoadHndl,
"delete": s.imgDeleteHndl,
"commit": s.imgCommitHndl,
"cleanup": s.imgCleanupHndl,
"info": s.imgInfoHndl,
})
}
@@ -8,7 +8,6 @@ package server
import (
"fmt"
"math/rand"
"net"
"net/http"
"testing"
@@ -20,34 +19,38 @@ import (
"github.com/umputun/remark42/memory_store/accessor"
)
func chooseRandomUnusedPort() (port int) {
for i := 0; i < 10; i++ {
port = 40000 + int(rand.Int31n(10000))
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
_ = ln.Close()
break
}
}
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
// collision between concurrently running package test binaries very unlikely
func chooseUnusedPort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err, "no free port available")
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
return port
}
func waitForHTTPServerStart(port int) {
// wait for up to 3 seconds for server to start before returning it
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
// port if it never does
func waitForHTTPServerStart(t *testing.T, port int) {
t.Helper()
client := http.Client{Timeout: time.Second}
for i := 0; i < 300; i++ {
time.Sleep(time.Millisecond * 10)
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
_ = resp.Body.Close()
return
defer client.CloseIdleConnections()
require.Eventually(t, func() bool {
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
if err != nil {
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()) {
mg := accessor.NewMemData()
adm := accessor.NewMemAdminStore("secret")
img := accessor.NewMemImageStore()
s := NewRPC(mg, adm, img, &jrpc.Server{API: "/test", Logger: jrpc.NoOpLogger})
s := NewRPC(mg, adm, img, jrpc.NewServer("/test"))
admRec := accessor.AdminRec{
SiteID: "test-site",
@@ -61,14 +64,17 @@ func prepTestStore(t *testing.T) (port int, teardown func()) {
admRecDisabled.Enabled = false
adm.Set("test-site-disabled", admRecDisabled)
port = chooseRandomUnusedPort()
port = chooseUnusedPort(t)
go func() {
_ = s.Run(port)
}()
waitForHTTPServerStart(port)
waitForHTTPServerStart(t, port)
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())
}
}
+7 -7
View File
@@ -1,13 +1,13 @@
package cmd
import (
"fmt"
"path"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
bolt "go.etcd.io/bbolt"
"github.com/go-pkgz/auth/avatar"
"github.com/go-pkgz/auth/v2/avatar"
)
// AvatarCommand set of flags and command for avatar migration
@@ -39,12 +39,12 @@ func (ac *AvatarCommand) Execute(_ []string) error {
src, err := ac.makeAvatarStore(ac.AvatarSrc)
if err != nil {
return errors.Wrapf(err, "can't make avatart store for %s", ac.AvatarSrc.Type)
return fmt.Errorf("can't make avatart store for %s: %w", ac.AvatarSrc.Type, err)
}
dst, err := ac.makeAvatarStore(ac.AvatarDst)
if err != nil {
return errors.Wrapf(err, "can't make avatart store for %s", ac.AvatarDst.Type)
return fmt.Errorf("can't make avatart store for %s: %w", ac.AvatarDst.Type, err)
}
if ac.migrator == nil {
@@ -72,14 +72,14 @@ func (ac *AvatarCommand) makeAvatarStore(gr AvatarGroup) (avatar.Store, error) {
switch gr.Type {
case "fs":
if err := makeDirs(gr.FS.Path); err != nil {
return nil, errors.Wrap(err, "failed to create avatar store")
return nil, fmt.Errorf("failed to create avatar store: %w", err)
}
return avatar.NewLocalFS(gr.FS.Path), nil
case "bolt":
if err := makeDirs(path.Dir(gr.Bolt.File)); err != nil {
return nil, errors.Wrap(err, "failed to create avatar store")
return nil, fmt.Errorf("failed to create avatar store: %w", err)
}
return avatar.NewBoltDB(gr.Bolt.File, bolt.Options{})
}
return nil, errors.Errorf("unsupported avatar store type %s", gr.Type)
return nil, fmt.Errorf("unsupported avatar store type %s", gr.Type)
}
+6 -5
View File
@@ -1,18 +1,17 @@
package cmd
import (
"errors"
"fmt"
"os"
"testing"
"github.com/go-pkgz/auth/avatar"
"github.com/go-pkgz/auth/v2/avatar"
"github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/go-flags"
)
func TestAvatar_Execute(t *testing.T) {
defer os.RemoveAll("/tmp/ava-test")
// from fs to bolt
@@ -22,16 +21,18 @@ func TestAvatar_Execute(t *testing.T) {
_, err := p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=bolt",
"--dst.bolt.file=/tmp/ava-test.db"})
require.NoError(t, err)
defer os.Remove("/tmp/ava-test.db")
err = cmd.Execute(nil)
assert.NoError(t, err)
// failed
cmd = AvatarCommand{migrator: &avatarMigratorMock{retCount: 0, retError: errors.New("failed blah")}}
cmd = AvatarCommand{migrator: &avatarMigratorMock{retCount: 0, retError: fmt.Errorf("failed blah")}}
cmd.SetCommon(CommonOpts{RemarkURL: "", SharedSecret: "123456"})
p = flags.NewParser(&cmd, flags.Default)
_, err = p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=bolt",
"--dst.bolt.file=/tmp/ava-test2.db"})
require.NoError(t, err)
defer os.Remove("/tmp/ava-test2.db")
err = cmd.Execute(nil)
assert.Error(t, err, "failed blah")
}
+13 -14
View File
@@ -9,17 +9,15 @@ import (
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
// BackupCommand set of flags and command for export
// ExportPath used as a separate element to leverage BACKUP_PATH. If ExportFile has a path (i.e. with /) BACKUP_PATH ignored.
type BackupCommand struct {
ExportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"`
ExportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.TS}}.gz" description:"file name"`
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
Timeout time.Duration `long:"timeout" default:"15m" description:"export (backup) timeout"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
ExportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"`
ExportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.TS}}.gz" description:"file name"`
SupportCmdOpts
CommonOpts
}
@@ -38,19 +36,20 @@ func (ec *BackupCommand) Execute(_ []string) error {
// prepare http client and request
client := http.Client{}
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), ec.Timeout)
defer cancel()
exportURL := fmt.Sprintf("%s/api/v1/admin/export?mode=file&site=%s", ec.RemarkURL, ec.Site)
req, err := http.NewRequest(http.MethodGet, exportURL, nil)
req, err := http.NewRequest(http.MethodGet, exportURL, http.NoBody)
if err != nil {
return errors.Wrapf(err, "can't make export request for %s", exportURL)
return fmt.Errorf("can't make export request for %s: %w", exportURL, err)
}
req.SetBasicAuth("admin", ec.AdminPasswd)
// get with timeout
resp, err := client.Do(req.WithContext(ctx))
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // exportURL is built from operator-supplied CLI flags, not user input
if err != nil {
return errors.Wrapf(err, "request failed for %s", exportURL)
return fmt.Errorf("request failed for %s: %w", exportURL, err)
}
defer func() {
if err = resp.Body.Close(); err != nil {
@@ -62,18 +61,18 @@ func (ec *BackupCommand) Execute(_ []string) error {
return responseError(resp)
}
fh, err := os.Create(fname)
fh, err := os.Create(fname) //nolint:gosec // harmless
if err != nil {
return errors.Wrapf(err, "can't create backup file %s", fname)
return fmt.Errorf("can't create backup file %s: %w", fname, err)
}
defer func() {
defer func() { //nolint:gosec // false positive on defer without error check when it's checked here
if err = fh.Close(); err != nil {
log.Printf("[WARN] failed to close file %s, %s", fh.Name(), err)
}
}()
if _, err = io.Copy(fh, resp.Body); err != nil {
return errors.Wrapf(err, "failed to write backup file %s", fname)
return fmt.Errorf("failed to write backup file %s: %w", fname, err)
}
log.Printf("[INFO] export completed, file %s", fname)
+30 -4
View File
@@ -1,15 +1,15 @@
package cmd
import (
"encoding/base64"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/umputun/go-flags"
"github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,6 +18,10 @@ func TestBackup_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
assert.Equal(t, "GET", r.Method)
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
require.NoError(t, err)
assert.Equal(t, "admin:secret", string(auth))
fmt.Fprint(w, "blah\nblah2\n12345678\n")
}))
defer ts.Close()
@@ -31,11 +35,33 @@ func TestBackup_Execute(t *testing.T) {
assert.NoError(t, err)
defer os.Remove("/tmp/remark-test.export")
data, err := ioutil.ReadFile("/tmp/remark-test.export")
data, err := os.ReadFile("/tmp/remark-test.export")
require.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(data))
}
func TestBackup_ExecuteNoPassword(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
assert.Equal(t, "GET", r.Method)
t.Logf("Authorization: %+v", r.Header.Get("Authorization"))
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
require.NoError(t, err)
require.Equal(t, "admin:", string(auth))
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "Unauthorized")
}))
defer ts.Close()
cmd := BackupCommand{}
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export"})
require.NoError(t, err)
err = cmd.Execute(nil)
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
}
func TestBackup_ExecuteFailedStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
+37 -39
View File
@@ -9,21 +9,20 @@ import (
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store"
)
// CleanupCommand set of flags and command for cleanup
type CleanupCommand struct {
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
Dry bool `long:"dry" description:"dry mode, will not remove comments"`
From string `long:"from" description:"from yyyymmdd"`
To string `long:"to" description:"from yyyymmdd"`
BadWords []string `short:"w" long:"bword" description:"bad word(s)"`
BadUsers []string `short:"u" long:"buser" description:"bad user(s)"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
SetTitle bool `long:"title" description:"title mode, will not remove comments, but reset titles to page's title'"`
Dry bool `long:"dry" description:"dry mode, will not remove comments"`
From string `long:"from" description:"from yyyymmdd"`
To string `long:"to" description:"from yyyymmdd"`
BadWords []string `short:"w" long:"bword" description:"bad word(s)"`
BadUsers []string `short:"u" long:"buser" description:"bad user(s)"`
SetTitle bool `long:"title" description:"title mode, will not remove comments, but reset titles to page's title'"`
SupportCmdOpts
CommonOpts
}
@@ -39,7 +38,7 @@ func (cc *CleanupCommand) Execute(_ []string) error {
posts, err := cc.postsInRange(cc.From, cc.To)
if err != nil {
return errors.Wrap(err, "can't get posts")
return fmt.Errorf("can't get posts: %w", err)
}
log.Printf("[DEBUG] got %d posts", len(posts))
@@ -55,7 +54,6 @@ func (cc *CleanupCommand) Execute(_ []string) error {
cc.procTitles(comments)
} else {
spamComments += cc.procSpam(comments)
}
}
@@ -79,7 +77,7 @@ func (cc *CleanupCommand) procSpam(comments []store.Comment) int {
log.Printf("[WARN] can't remove comment, %v", err)
}
}
comment.Text = strings.Replace(comment.Text, "\n", " ", -1)
comment.Text = strings.ReplaceAll(comment.Text, "\n", " ")
log.Printf("[SPAM] %+v [%.0f%%]", comment, score)
}
}
@@ -100,7 +98,7 @@ func (cc *CleanupCommand) procTitles(comments []store.Comment) {
func (cc *CleanupCommand) postsInRange(fromS, toS string) ([]store.PostInfo, error) {
posts, err := cc.listPosts()
if err != nil {
return nil, errors.Wrapf(err, "can't list posts for %s", cc.Site)
return nil, fmt.Errorf("can't list posts for %s: %w", cc.Site, err)
}
from, to := defaultFrom, defaultTo
@@ -108,14 +106,14 @@ func (cc *CleanupCommand) postsInRange(fromS, toS string) ([]store.PostInfo, err
if fromS != "" {
from, err = time.ParseInLocation("20060102", fromS, time.Local)
if err != nil {
return nil, errors.Wrap(err, "can't parse --from")
return nil, fmt.Errorf("can't parse --from: %w", err)
}
}
if toS != "" {
to, err = time.ParseInLocation("20060102", toS, time.Local)
if err != nil {
return nil, errors.Wrap(err, "can't parse --to")
return nil, fmt.Errorf("can't parse --to: %w", err)
}
}
@@ -132,37 +130,38 @@ func (cc *CleanupCommand) postsInRange(fromS, toS string) ([]store.PostInfo, err
func (cc *CleanupCommand) listPosts() ([]store.PostInfo, error) {
listURL := fmt.Sprintf("%s/api/v1/list?site=%s&limit=10000", cc.RemarkURL, cc.Site)
client := http.Client{Timeout: 30 * time.Second}
defer client.CloseIdleConnections()
r, err := client.Get(listURL)
if err != nil {
return nil, errors.Wrapf(err, "get request failed for list of posts, site %s", cc.Site)
return nil, fmt.Errorf("get request failed for list of posts, site %s: %w", cc.Site, err)
}
defer func() { _ = r.Body.Close() }()
if r.StatusCode != 200 {
return nil, errors.Errorf("request %s failed with status %d", listURL, r.StatusCode)
return nil, fmt.Errorf("request %s failed with status %d", listURL, r.StatusCode)
}
list := []store.PostInfo{}
if err = json.NewDecoder(r.Body).Decode(&list); err != nil {
return nil, errors.Wrapf(err, "can't decode list of posts for site %s", cc.Site)
return nil, fmt.Errorf("can't decode list of posts for site %s: %w", cc.Site, err)
}
return list, nil
}
// get all comments for post url via /find?site=siteID&url=post-url&format=[tree|plain]
func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error) {
commentsURL := fmt.Sprintf("%s/api/v1/find?site=%s&url=%s&format=plain", cc.RemarkURL, cc.Site, postURL)
var r *http.Response
var err error
// handle 429 error from limiter
client := http.Client{Timeout: 30 * time.Second}
defer client.CloseIdleConnections()
for {
client := http.Client{Timeout: 30 * time.Second}
r, err = client.Get(commentsURL)
if err != nil {
return nil, errors.Wrapf(err, "get request failed for comments, %s", postURL)
return nil, fmt.Errorf("get request failed for comments, %s: %w", postURL, err)
}
if r.StatusCode == http.StatusTooManyRequests {
_ = r.Body.Close()
@@ -175,67 +174,66 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error)
defer func() { _ = r.Body.Close() }()
if r.StatusCode != http.StatusOK {
return nil, errors.Errorf("request %s failed with status %d", commentsURL, r.StatusCode)
return nil, fmt.Errorf("request %s failed with status %d", commentsURL, r.StatusCode)
}
commentsWithInfo := struct {
Comments []store.Comment `json:"comments"`
Info store.PostInfo `json:"info,omitempty"`
Info store.PostInfo `json:"info"`
}{}
if err = json.NewDecoder(r.Body).Decode(&commentsWithInfo); err != nil {
return nil, errors.Wrapf(err, "can't decode list of comments for %s", postURL)
return nil, fmt.Errorf("can't decode list of comments for %s: %w", postURL, err)
}
return commentsWithInfo.Comments, nil
}
// deleteComment with DELETE /admin/comment/{id}?site=siteID&url=post-url
func (cc *CleanupCommand) deleteComment(c store.Comment) error {
func (cc *CleanupCommand) deleteComment(c store.Comment) error { //nolint:dupl // not worth combining
deleteURL := fmt.Sprintf("%s/api/v1/admin/comment/%s?site=%s&url=%s&format=plain", cc.RemarkURL, c.ID, cc.Site, c.Locator.URL)
req, err := http.NewRequest("DELETE", deleteURL, nil)
req, err := http.NewRequest("DELETE", deleteURL, http.NoBody)
if err != nil {
return errors.Wrapf(err, "failed to make delete request for comment %s, %s", c.ID, c.Locator.URL)
return fmt.Errorf("failed to make delete request for comment %s, %s: %w", c.ID, c.Locator.URL, err)
}
req.SetBasicAuth("admin", cc.AdminPasswd)
client := http.Client{}
r, err := client.Do(req)
defer client.CloseIdleConnections()
r, err := client.Do(req) //nolint:gosec // RemarkURL comes from operator CLI flag, not user input
if err != nil {
return errors.Wrapf(err, "delete request failed for comment %s, %s", c.ID, c.Locator.URL)
return fmt.Errorf("delete request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
}
defer func() { _ = r.Body.Close() }()
if r.StatusCode != http.StatusOK {
return errors.Errorf("delete request failed with status %s", r.Status)
return fmt.Errorf("delete request failed with status %s", r.Status)
}
return nil
}
// setTitle with PUT /admin/title/{id}?site=siteID&url=post-url
func (cc *CleanupCommand) setTitle(c store.Comment) error {
func (cc *CleanupCommand) setTitle(c store.Comment) error { //nolint:dupl // not worth combining
titleURL := fmt.Sprintf("%s/api/v1/admin/title/%s?site=%s&url=%s&format=plain", cc.RemarkURL, c.ID, cc.Site, c.Locator.URL)
req, err := http.NewRequest("PUT", titleURL, nil)
req, err := http.NewRequest("PUT", titleURL, http.NoBody)
if err != nil {
return errors.Wrapf(err, "failed to make title request for comment %s, %s", c.ID, c.Locator.URL)
return fmt.Errorf("failed to make title request for comment %s, %s: %w", c.ID, c.Locator.URL, err)
}
req.SetBasicAuth("admin", cc.AdminPasswd)
client := http.Client{}
r, err := client.Do(req)
defer client.CloseIdleConnections()
r, err := client.Do(req) //nolint:gosec // RemarkURL comes from operator CLI flag, not user input
if err != nil {
return errors.Wrapf(err, "title request failed for comment %s, %s", c.ID, c.Locator.URL)
return fmt.Errorf("title request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
}
defer func() { _ = r.Body.Close() }()
if r.StatusCode != http.StatusOK {
return errors.Errorf("title request failed with status %s", r.Status)
return fmt.Errorf("title request failed with status %s", r.Status)
}
return nil
}
// isSpam calculates spam's probability as a score
func (cc *CleanupCommand) isSpam(comment store.Comment) (isSpam bool, spamScore float64) {
badWord := func(txt string) float64 {
res := 0.0
for _, w := range cc.BadWords {
+9 -13
View File
@@ -9,10 +9,9 @@ import (
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/go-flags"
"github.com/umputun/remark42/backend/app/store"
)
@@ -46,7 +45,6 @@ func TestCleanup_IsSpam(t *testing.T) {
}
for n, tt := range tbl {
tt := tt
checkName := fmt.Sprintf("check-%d-%s", n, tt.name)
t.Run(checkName, func(t *testing.T) {
c := store.Comment{ID: checkName, Text: tt.text, Score: tt.score}
@@ -59,8 +57,7 @@ func TestCleanup_IsSpam(t *testing.T) {
}
func TestCleanup_postsInRange(t *testing.T) {
r := chi.NewRouter()
r := http.NewServeMux()
cleanupRoutes(t, r, nil)
ts := httptest.NewServer(r)
defer ts.Close()
@@ -83,7 +80,7 @@ func TestCleanup_postsInRange(t *testing.T) {
}
func TestCleanup_listComments(t *testing.T) {
r := chi.NewRouter()
r := http.NewServeMux()
cleanupRoutes(t, r, nil)
ts := httptest.NewServer(r)
defer ts.Close()
@@ -109,7 +106,7 @@ func TestCleanup_listComments(t *testing.T) {
func TestCleanup_ExecuteSpam(t *testing.T) {
cleaned := cleanedComments{}
r := chi.NewRouter()
r := http.NewServeMux()
cleanupRoutes(t, r, &cleaned)
ts := httptest.NewServer(r)
defer ts.Close()
@@ -128,7 +125,7 @@ func TestCleanup_ExecuteSpam(t *testing.T) {
func TestCleanup_ExecuteTitle(t *testing.T) {
titledComments := cleanedComments{}
r := chi.NewRouter()
r := http.NewServeMux()
cleanupRoutes(t, r, &titledComments)
ts := httptest.NewServer(r)
defer ts.Close()
@@ -144,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)
}
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) {
require.Equal(t, "GET", r.Method)
require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery)
@@ -175,7 +172,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
commentsWithInfo := struct {
Comments []store.Comment `json:"comments"`
Info store.PostInfo `json:"info,omitempty"`
Info store.PostInfo `json:"info"`
}{}
switch r.URL.Query().Get("url") {
@@ -196,7 +193,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
require.NoError(t, json.NewEncoder(w).Encode(commentsWithInfo))
})
r.HandleFunc("/api/v1/admin/comment/{id}", func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("/api/v1/admin/comment/{id}", func(_ http.ResponseWriter, r *http.Request) {
require.Equal(t, "DELETE", r.Method)
t.Log("delete ", r.URL.Path)
c.lock.Lock()
@@ -204,12 +201,11 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
c.lock.Unlock()
})
r.HandleFunc("/api/v1/admin/title/{id}", func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("/api/v1/admin/title/{id}", func(_ http.ResponseWriter, r *http.Request) {
require.Equal(t, "PUT", r.Method)
t.Log("title for ", r.URL.Path)
c.lock.Lock()
c.ids = append(c.ids, r.URL.Path)
c.lock.Unlock()
})
}
+22 -11
View File
@@ -4,7 +4,8 @@ package cmd
import (
"bytes"
"io/ioutil"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -13,7 +14,6 @@ import (
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
// CommonOptionsCommander extends flags.Commander with SetCommon
@@ -31,11 +31,20 @@ type CommonOpts struct {
Revision string
}
// SupportCmdOpts is set of commands shared among similar commands like backup/restore and such.
// Order of fields defines the help command output order.
type SupportCmdOpts struct {
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
Timeout time.Duration `long:"timeout" default:"60m" description:"timeout for the command run"`
}
// DeprecatedFlag contains information about deprecated option
type DeprecatedFlag struct {
Old string
New string
RemoveVersion string
Old string
New string
Version string
Collision bool
}
// SetCommon satisfies CommonOptionsCommander interface and sets common option fields
@@ -58,7 +67,6 @@ type fileParser struct {
// parse apply template and also concat path and file. In case if file contains path separator path will be ignored
func (p *fileParser) parse(now time.Time) (string, error) {
// file/location parameters my have template masks
fileTemplate := struct {
YYYYMMDD string
@@ -87,7 +95,7 @@ func (p *fileParser) parse(now time.Time) (string, error) {
}
if err := template.Must(template.New("bb").Parse(fname)).Execute(&bb, fileTemplate); err != nil {
return "", errors.Wrapf(err, "failed to parse %q", fname)
return "", fmt.Errorf("failed to parse %q: %w", fname, err)
}
return bb.String(), nil
}
@@ -103,18 +111,21 @@ func resetEnv(envs ...string) {
// responseError returns error with status and response body
func responseError(resp *http.Response) error {
body, e := ioutil.ReadAll(resp.Body)
body, e := io.ReadAll(resp.Body)
if e != nil {
body = []byte("")
}
return errors.Errorf("error response %q, %s", resp.Status, body)
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("error response %q, ensure you have set ADMIN_PASSWD and provided it to the command you're running: %s", resp.Status, body)
}
return fmt.Errorf("error response %q, %s", resp.Status, body)
}
// mkdir -p for all dirs
func makeDirs(dirs ...string) error {
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0700); err != nil { // If path is already a directory, MkdirAll does nothing
return errors.Wrapf(err, "can't make directory %s", dir)
if err := os.MkdirAll(dir, 0o700); err != nil { // if path is already a directory, MkdirAll does nothing
return fmt.Errorf("can't make directory %s: %w", dir, err)
}
}
return nil
+13 -16
View File
@@ -5,23 +5,19 @@ import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
// ImportCommand set of flags and command for import
type ImportCommand struct {
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" description:"import format"` //nolint
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" choice:"commento" description:"import format"` //nolint
SupportCmdOpts
CommonOpts
}
@@ -32,22 +28,23 @@ func (ic *ImportCommand) Execute(_ []string) error {
reader, err := ic.reader(ic.InputFile)
if err != nil {
return errors.Wrapf(err, "can't open import file %s", ic.InputFile)
return fmt.Errorf("can't open import file %s: %w", ic.InputFile, err)
}
client := http.Client{}
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), ic.Timeout)
defer cancel()
importURL := fmt.Sprintf("%s/api/v1/admin/import?site=%s&provider=%s", ic.RemarkURL, ic.Site, ic.Provider)
req, err := http.NewRequest(http.MethodPost, importURL, reader)
if err != nil {
return errors.Wrapf(err, "can't make import request for %s", importURL)
return fmt.Errorf("can't make import request for %s: %w", importURL, err)
}
req.SetBasicAuth("admin", ic.AdminPasswd)
resp, err := client.Do(req.WithContext(ctx)) // closes request's reader
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // importURL built from operator CLI flags, not user input; closes request's reader
if err != nil {
return errors.Wrapf(err, "request failed for %s", importURL)
return fmt.Errorf("request failed for %s: %w", importURL, err)
}
defer func() {
if err = resp.Body.Close(); err != nil {
@@ -58,9 +55,9 @@ func (ic *ImportCommand) Execute(_ []string) error {
return responseError(resp)
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "can't get response from importer")
return fmt.Errorf("can't get response from importer: %w", err)
}
log.Printf("[INFO] completed, status=%d, %s", resp.StatusCode, string(body))
@@ -71,13 +68,13 @@ func (ic *ImportCommand) Execute(_ []string) error {
func (ic *ImportCommand) reader(inp string) (reader io.Reader, err error) {
inpFile, err := os.Open(inp) // nolint
if err != nil {
return nil, errors.Wrapf(err, "import failed, can't open %s", inp)
return nil, fmt.Errorf("import failed, can't open %s: %w", inp, err)
}
reader = inpFile
if strings.HasSuffix(ic.InputFile, ".gz") {
if reader, err = gzip.NewReader(inpFile); err != nil {
return nil, errors.Wrap(err, "can't make gz reader")
return nil, fmt.Errorf("can't make gz reader: %w", err)
}
}
return reader, nil
+49 -13
View File
@@ -1,26 +1,29 @@
package cmd
import (
"encoding/base64"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
log "github.com/go-pkgz/lgr"
"github.com/umputun/go-flags"
"github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestImport_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
body, err := ioutil.ReadAll(r.Body)
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
require.NoError(t, err)
assert.Equal(t, "admin:secret", string(auth))
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
@@ -48,8 +51,43 @@ func TestImport_Execute(t *testing.T) {
assert.NoError(t, err)
}
func TestImport_ExecuteFailed(t *testing.T) {
func TestImport_ExecuteNoPassword(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
require.NoError(t, err)
assert.Equal(t, "admin:", string(auth))
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
}))
defer ts.Close()
cmd := ImportCommand{}
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt"})
require.NoError(t, err)
err = cmd.Execute(nil)
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
cmd = ImportCommand{}
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
p = flags.NewParser(&cmd, flags.Default)
_, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt.gz"})
require.NoError(t, err)
err = cmd.Execute(nil)
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
}
func TestImport_ExecuteFailed(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
@@ -94,16 +132,14 @@ func TestImport_ExecuteFailed(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, "POST", r.Method)
body, err := ioutil.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
time.Sleep(500 * time.Millisecond)
fmt.Fprintln(w, "some response")
fmt.Fprintln(w, string(body))
// hold the response until the client gives up on its own timeout
<-r.Context().Done()
}))
defer ts.Close()
+12 -14
View File
@@ -3,22 +3,19 @@ package cmd
import (
"context"
"fmt"
"io/ioutil"
"io"
"net/http"
"os"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
// RemapCommand set of flags and command for change linkage between comments to
// different urls based on given rules (input file)
type RemapCommand struct {
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
Timeout time.Duration `long:"timeout" default:"15m" description:"remap timeout"`
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
SupportCmdOpts
CommonOpts
}
@@ -29,22 +26,23 @@ func (rc *RemapCommand) Execute(_ []string) error {
rulesReader, err := os.Open(rc.InputFile)
if err != nil {
return errors.Wrapf(err, "cant open file %s", rc.InputFile)
return fmt.Errorf("cant open file %s: %w", rc.InputFile, err)
}
client := http.Client{}
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), rc.Timeout)
defer cancel()
remapURL := fmt.Sprintf("%s/api/v1/admin/remap?site=%s", rc.RemarkURL, rc.Site)
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader)
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader) //nolint:gosec // RemarkURL is operator CLI flag, not user input
if err != nil {
return errors.Wrapf(err, "can't make remap request for %s", remapURL)
return fmt.Errorf("can't make remap request for %s: %w", remapURL, err)
}
req.SetBasicAuth("admin", rc.AdminPasswd)
resp, err := client.Do(req.WithContext(ctx))
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // see above
if err != nil {
return errors.Wrapf(err, "request failed for %s", remapURL)
return fmt.Errorf("request failed for %s: %w", remapURL, err)
}
defer func() {
if err = resp.Body.Close(); err != nil {
@@ -55,9 +53,9 @@ func (rc *RemapCommand) Execute(_ []string) error {
return responseError(resp)
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "can't get response")
return fmt.Errorf("can't get response: %w", err)
}
log.Printf("[INFO] completed, status=%d, %s", resp.StatusCode, string(body))
+38 -5
View File
@@ -1,24 +1,29 @@
package cmd
import (
"io/ioutil"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/umputun/go-flags"
"github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRemap_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "remark", r.URL.Query().Get("site"))
body, err := ioutil.ReadAll(r.Body)
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
require.NoError(t, err)
assert.Equal(t, "admin:secret", string(auth))
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "http://oldsite.com* https://newsite.com*\nhttp://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1", string(body))
@@ -35,3 +40,31 @@ func TestRemap_Execute(t *testing.T) {
err = cmd.Execute(nil)
assert.NoError(t, err)
}
func TestRemap_ExecuteNoPassword(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "remark", r.URL.Query().Get("site"))
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
require.NoError(t, err)
assert.Equal(t, "admin:", string(auth))
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "http://oldsite.com* https://newsite.com*\nhttp://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1", string(body))
w.WriteHeader(401)
fmt.Fprint(w, "Unauthorized")
}))
defer ts.Close()
cmd := RemapCommand{}
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/remap_urls.txt"})
require.NoError(t, err)
err = cmd.Execute(nil)
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
}
+5 -9
View File
@@ -11,9 +11,7 @@ type RestoreCommand struct {
ImportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"`
ImportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.YYYYMMDD}}.gz" description:"file name" required:"true"`
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
SupportCmdOpts
CommonOpts
}
@@ -29,12 +27,10 @@ func (rc *RestoreCommand) Execute(args []string) error {
return err
}
importer := ImportCommand{
InputFile: fname,
Site: rc.Site,
Provider: "native",
Timeout: rc.Timeout,
AdminPasswd: rc.AdminPasswd,
CommonOpts: rc.CommonOpts,
InputFile: fname,
Provider: "native",
SupportCmdOpts: rc.SupportCmdOpts,
CommonOpts: rc.CommonOpts,
}
return importer.Execute(args)
}
+3 -5
View File
@@ -2,24 +2,22 @@ package cmd
import (
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/umputun/go-flags"
"github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRestore_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "native", r.URL.Query().Get("provider"))
body, err := ioutil.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
+847 -287
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP
HRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t
KvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE
nUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf
JB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4
f63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK
I8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx
TjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+
oAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6
HD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG
GcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP
iukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF
6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH
x6XroMXsmbnsEw==
-----END PRIVATE KEY-----
+6
View File
@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgGH2MylyZjjRdauTk
xxXW6p8VSHqIeVRRKSJPg1xn6+KgCgYIKoZIzj0DAQehRANCAAS/mNzQ7aBbIBr3
DiHiJGIDEzi6+q3mmyhH6ZWQWFdFei2qgdyM1V6qtRPVq+yHBNSBebbR4noE/IYO
hMdWYrKn
-----END PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
This stub page would be replaced by the frontend statically built HTML during the Docker image build.
+28 -16
View File
@@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"os"
"os/signal"
@@ -8,7 +9,7 @@ import (
"syscall"
log "github.com/go-pkgz/lgr"
"github.com/umputun/go-flags"
"github.com/jessevdk/go-flags"
"github.com/umputun/remark42/backend/app/cmd"
)
@@ -23,8 +24,9 @@ type Opts struct {
CleanupCmd cmd.CleanupCommand `command:"cleanup"`
RemapCmd cmd.RemapCommand `command:"remap"`
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"`
SharedSecret string `long:"secret" env:"SECRET" required:"true" description:"shared secret key"`
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"`
// SharedSecret is only used in server command, but defined for all commands for historical reasons
SharedSecret string `long:"secret" env:"SECRET" required:"true" description:"the shared secret key used to sign JWT, should be a random, long, hard-to-guess string"`
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
}
@@ -45,10 +47,7 @@ func main() {
SharedSecret: opts.SharedSecret,
Revision: revision,
})
for _, entry := range c.HandleDeprecatedFlags() {
log.Printf("[WARN] --%s is deprecated and will be removed in v%s, please use --%s instead",
entry.Old, entry.RemoveVersion, entry.New)
}
logDeprecatedParams(c.HandleDeprecatedFlags())
err := c.Execute(args)
if err != nil {
log.Printf("[ERROR] failed with %+v", err)
@@ -57,11 +56,11 @@ func main() {
}
if _, err := p.Parse(); err != nil {
if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp {
var flagsErr *flags.Error
if errors.As(err, &flagsErr) && flagsErr.Type == flags.ErrHelp {
os.Exit(0)
} else {
os.Exit(1)
}
os.Exit(1)
}
}
@@ -73,21 +72,34 @@ func setupLog(dbg bool) {
log.Setup(log.Msec, log.LevelBraces)
}
// logs usual and "collision" deprecated parameters
func logDeprecatedParams(params []cmd.DeprecatedFlag) {
for _, entry := range params {
var deprecationNote string
if entry.Collision {
deprecationNote = fmt.Sprintf("[ERROR] deprecated --%s and new --%s options are set to different values, old one is ignored: please remove it", entry.Old, entry.New)
} else {
deprecationNote = fmt.Sprintf("[WARN] --%s is deprecated since v%s and will be removed in the future", entry.Old, entry.Version)
if entry.New != "" {
deprecationNote += fmt.Sprintf(", please use --%s instead", entry.New)
}
}
log.Print(deprecationNote)
}
}
// getDump reads runtime stack and returns as a string
func getDump() string {
maxSize := 5 * 1024 * 1024
stacktrace := make([]byte, maxSize)
length := runtime.Stack(stacktrace, true)
if length > maxSize {
length = maxSize
}
length := min(runtime.Stack(stacktrace, true), maxSize)
return string(stacktrace[:length])
}
// nolint:gochecknoinits // can't avoid it in this place
//nolint:gochecknoinits // can't avoid it in this place
func init() {
// catch SIGQUIT and print stack traces
sigChan := make(chan os.Signal)
sigChan := make(chan os.Signal, 1)
go func() {
for range sigChan {
log.Printf("[INFO] SIGQUIT detected, dump:\n%s", getDump())
+103 -28
View File
@@ -2,13 +2,14 @@ package main
import (
"fmt"
"io/ioutil"
"math/rand"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -19,12 +20,11 @@ import (
)
func Test_Main(t *testing.T) {
dir, err := ioutil.TempDir(os.TempDir(), "remark42")
dir, err := os.MkdirTemp(os.TempDir(), "remark42")
require.NoError(t, err)
defer os.RemoveAll(dir)
port := chooseRandomUnusedPort()
port := chooseUnusedPort(t)
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"}
@@ -47,52 +47,127 @@ func Test_Main(t *testing.T) {
<-finished
}()
waitForHTTPServerStart(port)
waitForHTTPServerStart(t, port)
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
}
func TestMain_WithWebhook(t *testing.T) {
dir, err := os.MkdirTemp(os.TempDir(), "remark42")
require.NoError(t, err)
defer os.RemoveAll(dir)
var webhookSent atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
webhookSent.Store(1)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
b, e := io.ReadAll(r.Body)
defer r.Body.Close()
assert.Nil(t, e)
assert.Equal(t, "Comment: env test", string(b))
}))
defer ts.Close()
port := chooseUnusedPort(t)
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",
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
err = os.Setenv("NOTIFY_WEBHOOK_URL", ts.URL)
assert.NoError(t, err)
err = os.Setenv("NOTIFY_WEBHOOK_TEMPLATE", "Comment: {{.Orig}}")
assert.NoError(t, err)
err = os.Setenv("NOTIFY_WEBHOOK_HEADERS", "Content-Type:application/json")
assert.NoError(t, err)
done := make(chan struct{})
go func() {
<-done
e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
require.NoError(t, e)
}()
finished := make(chan struct{})
go func() {
main()
close(finished)
}()
// defer cleanup because require check below can fail
defer func() {
close(done)
<-finished
}()
waitForHTTPServerStart(t, 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"}}`))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
// wait for webhook to be sent before shutting down
assert.Eventually(t, func() bool {
return webhookSent.Load() == int32(1)
}, 30*time.Second, 10*time.Millisecond, "webhook was not sent")
}
func TestGetDump(t *testing.T) {
dump := getDump()
assert.True(t, strings.Contains(dump, "goroutine"))
assert.True(t, strings.Contains(dump, "[running]"))
assert.True(t, strings.Contains(dump, "backend/app/main.go"))
assert.Contains(t, dump, "goroutine")
assert.Contains(t, dump, "[running]")
assert.Contains(t, dump, "backend/app/main.go")
t.Logf("\n dump: %s", dump)
}
func chooseRandomUnusedPort() (port int) {
for i := 0; i < 10; i++ {
port = 40000 + int(rand.Int31n(10000))
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
_ = ln.Close()
break
}
}
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
// collision between concurrently running package test binaries very unlikely
func chooseUnusedPort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err, "no free port available")
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
return port
}
func waitForHTTPServerStart(port int) {
// wait for up to 10 seconds for server to start before returning it
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
// port if it never does
func waitForHTTPServerStart(t *testing.T, port int) {
t.Helper()
client := http.Client{Timeout: time.Second}
for i := 0; i < 100; i++ {
time.Sleep(time.Millisecond * 100)
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
_ = resp.Body.Close()
return
defer client.CloseIdleConnections()
require.Eventually(t, func() bool {
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
if err != nil {
return false
}
}
_ = 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) {
// both ignores are for leaks which are detected locally
goleak.VerifyTestMain(
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("github.com/umputun/remark42/backend/app.init.0.func1"),
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
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"),
)
}
+14 -10
View File
@@ -4,14 +4,12 @@ import (
"compress/gzip"
"context"
"fmt"
"io/ioutil"
"os"
"sort"
"strings"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
// AutoBackup struct handles daily backups params for siteID
@@ -27,6 +25,7 @@ type AutoBackup struct {
func (ab AutoBackup) Do(ctx context.Context) {
log.Printf("[INFO] activate auto-backup for %s under %s, duration %s", ab.SiteID, ab.BackupLocation, ab.Duration)
tick := time.NewTicker(ab.Duration)
defer tick.Stop()
log.Printf("[DEBUG] first backup for %s at %s", ab.SiteID, time.Now().Add(ab.Duration))
for {
@@ -48,38 +47,43 @@ func (ab AutoBackup) Do(ctx context.Context) {
func (ab AutoBackup) makeBackup() (string, error) {
log.Printf("[DEBUG] make backup for %s", ab.SiteID)
backupFile := fmt.Sprintf("%s/backup-%s-%s.gz", ab.BackupLocation, ab.SiteID, time.Now().Format("20060102"))
fh, err := os.Create(backupFile)
fh, err := os.Create(backupFile) //nolint:gosec // harmless
if err != nil {
return "", errors.Wrapf(err, "can't create backup file %s", backupFile)
return "", fmt.Errorf("can't create backup file %s: %w", backupFile, err)
}
gz := gzip.NewWriter(fh)
if _, err = ab.Exporter.Export(gz, ab.SiteID); err != nil {
return "", errors.Wrapf(err, "export failed for %s", ab.SiteID)
return "", fmt.Errorf("export failed for %s: %w", ab.SiteID, err)
}
if err = gz.Close(); err != nil {
return "", errors.Wrapf(err, "can't close gz for %s", backupFile)
return "", fmt.Errorf("can't close gz for %s: %w", backupFile, err)
}
if err = fh.Close(); err != nil {
return "", errors.Wrapf(err, "can't close file handler for %s", backupFile)
return "", fmt.Errorf("can't close file handler for %s: %w", backupFile, err)
}
log.Printf("[DEBUG] created backup file %s", backupFile)
return backupFile, nil
}
func (ab AutoBackup) removeOldBackupFiles() {
files, err := ioutil.ReadDir(ab.BackupLocation)
files, err := os.ReadDir(ab.BackupLocation)
if err != nil {
log.Printf("[WARN] can't read files in backup directory %s, %s", ab.BackupLocation, err)
return
}
backFiles := []os.FileInfo{}
for _, file := range files {
info, e := file.Info()
if e != nil {
log.Printf("[WARN] can't read info for directory %s, %s", file.Name(), e)
return
}
if strings.HasPrefix(file.Name(), "backup-"+ab.SiteID) {
backFiles = append(backFiles, file)
backFiles = append(backFiles, info)
}
}
sort.Slice(backFiles, func(i int, j int) bool { return backFiles[i].Name() < backFiles[j].Name() })
sort.Slice(backFiles, func(i, j int) bool { return backFiles[i].Name() < backFiles[j].Name() })
if len(backFiles) > ab.KeepMax {
for i := 0; i < len(backFiles)-ab.KeepMax; i++ {
+39 -22
View File
@@ -1,12 +1,13 @@
package migrator
import (
"compress/gzip"
"context"
"fmt"
"io"
"io/ioutil"
"os"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
@@ -17,20 +18,20 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) {
loc := "/tmp/remark-backups.test"
defer os.RemoveAll(loc)
assert.NoError(t, os.MkdirAll(loc, 0700))
assert.NoError(t, os.MkdirAll(loc, 0o700))
for i := 1; i <= 10; i++ {
fname := fmt.Sprintf("%s/backup-site1-201712%02d.gz", loc, i)
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
err := os.WriteFile(fname, []byte("blah"), 0o600)
assert.NoError(t, err)
}
fname := fmt.Sprintf("%s/backup-site2-20171210.gz", loc)
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
err := os.WriteFile(fname, []byte("blah"), 0o600)
assert.NoError(t, err)
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3}
bk.removeOldBackupFiles()
ff, err := ioutil.ReadDir(loc)
ff, err := os.ReadDir(loc)
assert.NoError(t, err)
require.Equal(t, 4, len(ff), "should keep 4 files - 3 kept for sit1, and one for site2")
assert.Equal(t, "backup-site1-20171208.gz", ff[0].Name())
@@ -42,7 +43,7 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) {
func TestBackup_MakeBackup(t *testing.T) {
loc := "/tmp/remark-backups.test"
defer os.RemoveAll(loc)
assert.NoError(t, os.MkdirAll(loc, 0700))
assert.NoError(t, os.MkdirAll(loc, 0o700))
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}}
fname, err := bk.makeBackup()
@@ -50,34 +51,50 @@ func TestBackup_MakeBackup(t *testing.T) {
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
assert.Equal(t, expFile, fname)
fi, err := os.Lstat(expFile)
assert.NoError(t, err)
assert.Equal(t, int64(52), fi.Size())
assert.Equal(t, exportedPayload, gzContent(t, expFile))
}
func TestBackup_Do(t *testing.T) {
loc := "/tmp/remark-backups.test"
defer os.RemoveAll(loc)
assert.NoError(t, os.MkdirAll(loc, 0700))
assert.NoError(t, os.MkdirAll(loc, 0o700))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(time.Second)
cancel()
}()
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(time.Second)
cancel()
}()
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}, Duration: 600 * time.Millisecond}
bk.Do(ctx)
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}, Duration: 600 * time.Millisecond}
bk.Do(ctx)
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
fi, err := os.Lstat(expFile)
assert.NoError(t, err)
assert.Equal(t, int64(52), fi.Size())
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
assert.Equal(t, exportedPayload, gzContent(t, expFile))
})
}
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{}
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
}
+151
View File
@@ -0,0 +1,151 @@
package migrator
import (
"encoding/json"
"fmt"
"io"
"net/url"
"time"
"github.com/umputun/remark42/backend/app/store"
log "github.com/go-pkgz/lgr"
)
// Commento implements Importer from commento export json
type Commento struct {
DataStore Store
}
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/domain_import_commento.go#L11-L15
type commentoExport struct {
Version int `json:"version"`
Comments []commentoComment `json:"comments"`
Commenters []commentoCommenter `json:"commenters"`
}
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/comment.go#L7-L20
type commentoComment struct {
CommentHex string `json:"commentHex"`
Domain string `json:"domain,omitempty"`
Path string `json:"url,omitempty"`
CommenterHex string `json:"commenterHex"`
Markdown string `json:"markdown"`
HTML string `json:"html"`
ParentHex string `json:"parentHex"`
Score int `json:"score"`
State string `json:"state,omitempty"`
CreationDate time.Time `json:"creationDate"`
Direction int `json:"direction"`
Deleted bool `json:"deleted"`
}
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/commenter.go#L7-L16
type commentoCommenter struct {
CommenterHex string `json:"commenterHex,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name"`
Link string `json:"link"`
Photo string `json:"photo"`
Provider string `json:"provider,omitempty"`
JoinDate time.Time `json:"joinDate"`
IsModerator bool `json:"isModerator"`
}
// Import comments from Commento and save to store
func (d *Commento) Import(r io.Reader, siteID string) (size int, err error) {
if e := d.DataStore.DeleteAll(siteID); e != nil {
return 0, e
}
commentsCh := d.convert(r, siteID)
failed, passed := 0, 0
for c := range commentsCh {
if _, err = d.DataStore.Create(c); err != nil {
failed++
continue
}
passed++
}
if failed > 0 {
err = fmt.Errorf("failed to save %d comments", failed)
if passed == 0 {
err = fmt.Errorf("import failed")
}
}
log.Printf("[DEBUG] imported %d comments to site %s", passed, siteID)
return passed, err
}
func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
commentsCh := make(chan store.Comment)
decoder := json.NewDecoder(r)
go func() {
var exportedData commentoExport
err := decoder.Decode(&exportedData)
if err != nil {
log.Printf("[WARN] can't decode commento export json, %s", err.Error())
}
usersMap := map[string]store.User{}
for _, commenter := range exportedData.Commenters {
usersMap[commenter.CommenterHex] = store.User{
Name: commenter.Name,
ID: "commento_" + store.EncodeID(commenter.CommenterHex),
Picture: commenter.Photo,
}
}
usersMap["anonymous"] = store.User{
Name: "Anonymous",
ID: "commento_" + store.EncodeID("anonymous"),
}
for _, comment := range exportedData.Comments {
u, ok := usersMap[comment.CommenterHex]
if !ok {
continue
}
if comment.Deleted {
continue
}
parentID := comment.ParentHex
// comments with ParentHex == "root" are top-level comments
if parentID == "root" {
parentID = ""
}
commentURL, e := url.JoinPath("https://", comment.Domain, comment.Path)
if e != nil {
log.Printf("[WARN] can't construct comment URL in commento import, %s", err.Error())
}
log.Printf("[ERROR] commentoURL: %s", commentURL)
c := store.Comment{
ID: comment.CommentHex,
Locator: store.Locator{
URL: commentURL,
SiteID: siteID,
},
User: u,
Text: comment.Markdown,
Timestamp: comment.CreationDate,
ParentID: parentID,
Imported: true,
}
commentsCh <- c
}
close(commentsCh)
}()
return commentsCh
}
+67
View File
@@ -0,0 +1,67 @@
package migrator
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestCommento_Import(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Commento{DataStore: &dataStore}
fh, err := os.Open("testdata/commento.json")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 3, size)
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 3, len(last), "3 comments imported")
t.Log(last[0])
c := last[0] // last reverses, get first one
assert.Equal(t, "Great reply!", c.Text)
assert.Equal(t, "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9", c.ID)
assert.Equal(t, "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854", c.ParentID)
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"}, c.Locator)
assert.Equal(t, "Saturnin Uf", c.User.Name)
assert.Equal(t, "commento_35369aeb6ac5255de30410a0f86dc71eb9c6d0ca", c.User.ID)
assert.True(t, c.Imported)
c = last[2] // anonymous comment
assert.Equal(t, "Example comment created by user.", c.Text)
assert.Equal(t, "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945", c.ID)
assert.Equal(t, "", c.ParentID)
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://example.com/blog/post/2"}, c.Locator)
assert.Equal(t, "Anonymous", c.User.Name)
assert.Equal(t, "commento_0a92fab3230134cca6eadd9898325b9b2ae67998", c.User.ID)
assert.True(t, c.Imported)
posts, err := dataStore.List("test", 0, 0)
assert.NoError(t, err)
assert.Equal(t, 2, len(posts), "2 posts")
count, err := dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"})
assert.NoError(t, err)
assert.Equal(t, 2, count)
count, err = dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/2"})
assert.NoError(t, err)
assert.Equal(t, 1, count)
}
+28 -12
View File
@@ -2,12 +2,12 @@ package migrator
import (
"encoding/xml"
"fmt"
"io"
"strings"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store"
)
@@ -44,6 +44,7 @@ type disqusComment struct {
Tid uid `xml:"thread"`
Pid uid `xml:"parent"`
IsSpam bool `xml:"isSpam"`
Deleted bool `xml:"isDeleted"`
}
type uid struct {
@@ -67,9 +68,9 @@ func (d *Disqus) Import(r io.Reader, siteID string) (size int, err error) {
}
if failed > 0 {
err = errors.Errorf("failed to save %d comments", failed)
err = fmt.Errorf("failed to save %d comments", failed)
if passed == 0 {
err = errors.New("import failed")
err = fmt.Errorf("import failed")
}
}
@@ -81,15 +82,15 @@ func (d *Disqus) Import(r io.Reader, siteID string) (size int, err error) {
// convert disqus stream (xml) from reader and fill channel of comments.
// runs async and closes channel on completion.
func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
postsMap := map[string]string{} // tid:url
decoder := xml.NewDecoder(r)
commentsCh := make(chan store.Comment)
stats := struct {
inpThreads, inpComments int
commentsCount, spamComments int
failedThreads, failedPosts int
inpThreads, inpComments int
commentsCount, spamComments int
failedThreads, failedPosts int
deletedComments, skippedComments int
}{}
go func() {
@@ -99,8 +100,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
break
}
switch se := t.(type) {
case xml.StartElement:
if se, ok := t.(xml.StartElement); ok {
if se.Name.Local == "thread" {
stats.inpThreads++
thread := disqusThread{}
@@ -109,9 +109,13 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
stats.failedThreads++
continue
}
if thread.Deleted {
continue
}
postsMap[thread.UID] = thread.Link
continue
}
if se.Name.Local == "post" {
stats.inpComments++
comment := disqusComment{}
@@ -120,13 +124,24 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
stats.failedPosts++
continue
}
if comment.Deleted {
stats.deletedComments++
continue
}
if comment.IsSpam {
stats.spamComments++
continue
}
url, ok := postsMap[comment.Tid.Val]
if !ok {
stats.skippedComments++
continue
}
c := store.Comment{
ID: comment.UID,
Locator: store.Locator{URL: postsMap[comment.Tid.Val], SiteID: siteID},
Locator: store.Locator{URL: url, SiteID: siteID},
User: store.User{
ID: "disqus_" + store.EncodeID(comment.AuthorUserName),
Name: comment.AuthorName,
@@ -159,7 +174,8 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
}
func (*Disqus) cleanText(text string) string {
text = strings.Replace(text, "\n", "", -1)
text = strings.Replace(text, "\t", "", -1)
text = strings.TrimSpace(text)
text = strings.ReplaceAll(text, "\n", "")
text = strings.ReplaceAll(text, "\t", "")
return text
}
+67 -155
View File
@@ -23,7 +23,9 @@ func TestDisqus_Import(t *testing.T) {
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Disqus{DataStore: &dataStore}
size, err := d.Import(strings.NewReader(xmlTestDisqus), "test")
fh, err := os.Open("testdata/disqus.xml")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 4, size)
@@ -54,11 +56,73 @@ func TestDisqus_Import(t *testing.T) {
assert.Equal(t, 2, count)
}
func TestDisqus_ImportDeletedThread(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Disqus{DataStore: &dataStore}
fh, err := os.Open("testdata/disqus-deleted-thread.xml")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 2, size)
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 2, len(last), "2 comments imported")
c := last[len(last)-1] // last reverses, get first one
assert.True(t, strings.HasPrefix(c.Text, "<p>Google App Engine "), c.Text)
assert.Equal(t, "299986072", c.ID)
assert.Equal(t, "", c.ParentID)
assert.Equal(t, store.Locator{SiteID: "test", URL: "http://radio-t.umputun.com/2011/03/229_8880.html"}, c.Locator)
assert.Equal(t, "No Username", c.User.Name)
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
assert.Equal(t, "7001968ea3f6c9013a9f0a3650f200c10c927638", c.User.IP)
assert.True(t, c.Imported)
c = last[1] // get comment with empty username
assert.Equal(t, "No Username", c.User.Name)
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
}
func TestDisqus_ImportDeletedPost(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Disqus{DataStore: &dataStore}
fh, err := os.Open("testdata/disqus-deleted-post.xml")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 3, size, "1 post deleted")
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 3, len(last), "3 comments imported")
c := last[len(last)-1] // last reverses, get first one
assert.True(t, strings.HasPrefix(c.Text, "<p>Microsoft "), c.Text)
assert.Equal(t, "299744309", c.ID)
assert.Equal(t, "", c.ParentID)
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://radio-t.com/p/2011/03/05/podcast-229/"}, c.Locator)
assert.Equal(t, "mikhail", c.User.Name)
assert.Equal(t, "disqus_1b6709749c0cab163db9070cc4edf3322b398d8c", c.User.ID)
assert.Equal(t, "9d3657a95a4e341510404bd8bf1a363faefd4ba4", c.User.IP)
assert.True(t, c.Imported)
}
func TestDisqus_Convert(t *testing.T) {
d := Disqus{}
ch := d.convert(strings.NewReader(xmlTestDisqus), "test")
fh, err := os.Open("testdata/disqus.xml")
require.NoError(t, err)
ch := d.convert(fh, "test")
res := []store.Comment{}
res := make([]store.Comment, 0, 4)
for comment := range ch {
res = append(res, comment)
}
@@ -81,155 +145,3 @@ func TestDisqus_Convert(t *testing.T) {
exp0.Timestamp, _ = time.Parse("2006-01-02T15:04:05Z", "2011-08-31T15:16:29Z")
assert.Equal(t, exp0, res[0])
}
var xmlTestDisqus = `<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>false</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
`
+6 -7
View File
@@ -1,9 +1,8 @@
package migrator
import (
"errors"
"fmt"
"io"
"io/ioutil"
"strings"
)
@@ -30,7 +29,7 @@ func NewURLMapper(reader io.Reader) (Mapper, error) {
// https://www.myblog.com/blog/1/ https://myblog.com/blog/1/
// https://www.myblog.com/* https://myblog.com/*
func (u *URLMapper) loadRules(reader io.Reader) error {
data, err := ioutil.ReadAll(reader)
data, err := io.ReadAll(reader)
if err != nil {
return err
}
@@ -39,11 +38,11 @@ func (u *URLMapper) loadRules(reader io.Reader) error {
u.rules = make(map[string]string)
for _, row := range strings.Split(rulesText, "\n") {
for row := range strings.SplitSeq(rulesText, "\n") {
row = strings.TrimSpace(row)
urls := strings.Split(row, " ")
if len(urls) != 2 {
return errors.New("bad row " + row)
return fmt.Errorf("bad row %s", row)
}
from, to := strings.TrimSpace(urls[0]), strings.TrimSpace(urls[1])
@@ -65,8 +64,8 @@ func (u *URLMapper) URL(url string) string {
}
oldURL = strings.TrimSuffix(oldURL, "*")
newURL = strings.TrimSuffix(newURL, "*")
if strings.HasPrefix(url, oldURL) {
return newURL + strings.TrimPrefix(url, oldURL)
if after, ok := strings.CutPrefix(url, oldURL); ok {
return newURL + after
}
}
// search failed, return given url
+7 -5
View File
@@ -4,11 +4,11 @@
package migrator
import (
"fmt"
"io"
"os"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
@@ -38,7 +38,7 @@ type MapperMaker func(reader io.Reader) (Mapper, error)
type Store interface {
Create(comment store.Comment) (commentID string, err error)
Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error)
List(siteID string, limit int, skip int) ([]store.PostInfo, error)
List(siteID string, limit, skip int) ([]store.PostInfo, error)
DeleteAll(siteID string) error
Metas(siteID string) (umetas []service.UserMetaData, pmetas []service.PostMetaData, err error)
SetMetas(siteID string, umetas []service.UserMetaData, pmetas []service.PostMetaData) error
@@ -64,18 +64,20 @@ func ImportComments(p ImportParams) (int, error) {
importer = &Disqus{DataStore: p.DataStore}
case "wordpress":
importer = &WordPress{DataStore: p.DataStore}
case "commento":
importer = &Commento{DataStore: p.DataStore}
case "native":
importer = &Native{DataStore: p.DataStore}
default:
return 0, errors.Errorf("unsupported import provider %s", p.Provider)
return 0, fmt.Errorf("unsupported import provider %s", p.Provider)
}
fh, err := os.Open(p.InputFile)
if err != nil {
return 0, errors.Wrapf(err, "can't open import file %s", p.InputFile)
return 0, fmt.Errorf("can't open import file %s: %w", p.InputFile, err)
}
defer func() {
defer func() { //nolint:gosec // false positive on defer without error check when it's checked here
if err = fh.Close(); err != nil {
log.Printf("[WARN] can't close %s, %s", p.InputFile, err)
}
+25 -11
View File
@@ -1,7 +1,6 @@
package migrator
import (
"io/ioutil"
"os"
"testing"
"time"
@@ -17,13 +16,7 @@ import (
)
func TestMigrator_ImportDisqus(t *testing.T) {
defer func() {
os.Remove("/tmp/remark-test.db")
os.Remove("/tmp/disqus-test.xml")
}()
err := ioutil.WriteFile("/tmp/disqus-test.xml", []byte(xmlTestDisqus), 0600)
require.NoError(t, err)
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
@@ -31,7 +24,7 @@ func TestMigrator_ImportDisqus(t *testing.T) {
defer dataStore.Close()
size, err := ImportComments(ImportParams{
DataStore: dataStore,
InputFile: "/tmp/disqus-test.xml",
InputFile: "testdata/disqus.xml",
SiteID: "test",
Provider: "disqus",
})
@@ -49,7 +42,7 @@ func TestMigrator_ImportWordPress(t *testing.T) {
os.Remove("/tmp/wordpress-test.xml")
}()
err := ioutil.WriteFile("/tmp/wordpress-test.xml", []byte(xmlTestWP), 0600)
err := os.WriteFile("/tmp/wordpress-test.xml", []byte(xmlTestWP), 0o600)
require.NoError(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
@@ -70,6 +63,27 @@ func TestMigrator_ImportWordPress(t *testing.T) {
assert.Equal(t, 3, len(last), "3 comments imported")
}
func TestMigrator_ImportCommento(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := &service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
size, err := ImportComments(ImportParams{
DataStore: dataStore,
InputFile: "testdata/commento.json",
SiteID: "test",
Provider: "commento",
})
assert.NoError(t, err)
assert.Equal(t, 3, size)
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
assert.NoError(t, err)
assert.Equal(t, 3, len(last), "3 comments imported")
}
func TestMigrator_ImportNative(t *testing.T) {
defer func() {
os.Remove("/tmp/remark-test.db")
@@ -79,7 +93,7 @@ func TestMigrator_ImportNative(t *testing.T) {
data := `{"version":1} {"id":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"","text":"some text, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","profile":"","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:22-06:00"}` + "\n" +
`{"id":"afbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","text":"some text2, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","profile":"","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00"}` + "\n"
err := ioutil.WriteFile("/tmp/disqus-test.r42", []byte(data), 0600)
err := os.WriteFile("/tmp/disqus-test.r42", []byte(data), 0o600)
require.NoError(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "radio-t"})
+12 -14
View File
@@ -4,12 +4,13 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"slices"
"sync/atomic"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/syncs"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
@@ -35,9 +36,8 @@ type meta struct {
// Export all comments to writer as json strings. Each comment is one string, separated by "\n"
// The final file is a valid json
func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
if err = n.exportMeta(siteID, w); err != nil {
return 0, errors.Wrapf(err, "failed to export meta for site %s", siteID)
return 0, fmt.Errorf("failed to export meta for site %s: %w", siteID, err)
}
topics, err := n.DataStore.List(siteID, 0, 0)
@@ -47,24 +47,23 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
log.Printf("[DEBUG] exporting %d topics", len(topics))
commentsCount := 0
for i := len(topics) - 1; i >= 0; i-- { // topics from List sorted in opposite direction
topic := topics[i]
for _, topic := range slices.Backward(topics) { // topics from List sorted in opposite direction
comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser)
if e != nil {
return commentsCount, e
}
for _, comment := range comments {
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
if err = enc.Encode(comment); err != nil {
return commentsCount, errors.Wrapf(err, "can't marshal %v", comments)
return commentsCount, fmt.Errorf("can't marshal %v: %w", comments, err)
}
if _, err = w.Write(buf.Bytes()); err != nil {
return commentsCount, errors.Wrap(err, "can't write comment data")
return commentsCount, fmt.Errorf("can't write comment data: %w", err)
}
commentsCount++
}
@@ -78,11 +77,11 @@ func (n *Native) exportMeta(siteID string, w io.Writer) (err error) {
m := meta{Version: nativeVersion}
m.Users, m.Posts, err = n.DataStore.Metas(siteID)
if err != nil {
return errors.Wrap(err, "can't get meta")
return fmt.Errorf("can't get meta: %w", err)
}
if err = json.NewEncoder(w).Encode(m); err != nil {
return errors.Wrap(err, "can't encode meta")
return fmt.Errorf("can't encode meta: %w", err)
}
return nil
}
@@ -133,11 +132,11 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
m := meta{}
dec := json.NewDecoder(reader)
if err = dec.Decode(&m); err != nil {
return 0, errors.Wrapf(err, "failed to import meta for site %s", siteID)
return 0, fmt.Errorf("failed to import meta for site %s: %w", siteID, err)
}
if m.Version != nativeVersion && m.Version != 0 { // this version allows back compatibility with 0 version
return 0, errors.Errorf("unexpected import file version %d", m.Version)
return 0, fmt.Errorf("unexpected import file version %d", m.Version)
}
if e := n.DataStore.DeleteAll(siteID); e != nil {
@@ -180,13 +179,12 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
log.Printf("[DEBUG] imported %d comments", num)
}
})
}
grp.Wait()
if failed > 0 {
return int(comments), errors.Errorf("failed to save %d comments", failed)
return int(comments), fmt.Errorf("failed to save %d comments", failed)
}
log.Printf("[INFO] imported %d comments from %d records", comments, total)
+2 -4
View File
@@ -153,7 +153,6 @@ func TestNative_ImportWrongVersion(t *testing.T) {
size, err := r.Import(strings.NewReader(inp), "radio-t")
assert.EqualError(t, err, "unexpected import file version 2")
assert.Equal(t, 0, size)
}
func TestNative_ImportManyWithError(t *testing.T) {
b, teardown := prep(t) // write 2 comments
@@ -163,8 +162,8 @@ func TestNative_ImportManyWithError(t *testing.T) {
buf := &bytes.Buffer{}
buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n")
for i := 0; i < 100; i++ {
buf.WriteString(fmt.Sprintf(goodRec, i))
for i := range 100 {
fmt.Fprintf(buf, goodRec, i)
}
buf.WriteString("{}\n")
buf.WriteString("{}\n")
@@ -181,7 +180,6 @@ func TestNative_ImportManyWithError(t *testing.T) {
// makes new boltdb, put two records
func prep(t *testing.T) (ds *service.DataStore, teardown func()) {
testDB := fmt.Sprintf("/tmp/migrator-%d.db", rand.Intn(999999999))
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{SiteID: "radio-t", FileName: testDB})
+69
View File
@@ -0,0 +1,69 @@
{
"version": 1,
"comments": [
{
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
"domain": "example.com",
"url": "/blog/post/2",
"commenterHex": "anonymous",
"markdown": "Example comment created by user.",
"html": "",
"parentHex": "root",
"score": 1,
"state": "approved",
"creationDate": "2021-03-12T11:21:56Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"domain": "example.com",
"url": "/blog/post/1",
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"markdown": "Example 2 comment created by user.",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2021-03-17T12:09:47.722181Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
"domain": "example.com",
"url": "/blog/post/1",
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
"markdown": "Great reply!",
"html": "",
"parentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"score": 0,
"state": "approved",
"creationDate": "2021-05-11T15:43:01.852651Z",
"direction": 0,
"deleted": false
}
],
"commenters": [
{
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"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
},
{
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
"email": "moregreatmail@gmail.com",
"name": "Saturnin Uf",
"link": "https://example.com/profile/259",
"photo": "https://secure.gravatar.com/avatar/6481228d190f0286a42bee9041f9b1a1",
"provider": "sso:example.com",
"joinDate": "2021-03-21T12:15:37.536035Z",
"isModerator": false
}
]
}
+150
View File
@@ -0,0 +1,150 @@
`<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>false</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>true</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
+150
View File
@@ -0,0 +1,150 @@
`<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>true</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
+150
View File
@@ -0,0 +1,150 @@
`<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>false</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
+7 -9
View File
@@ -2,12 +2,12 @@ package migrator
import (
"encoding/xml"
"fmt"
"html"
"io"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store"
)
@@ -16,7 +16,8 @@ const wpTimeLayout = "2006-01-02 15:04:05"
// WordPress implements Importer from WP xml
type WordPress struct {
DataStore Store
DataStore Store
DisableFancyTextFormatting bool
}
type wpItem struct {
@@ -60,7 +61,6 @@ func (w *WordPress) Convert(text string) string {
// Import comments from WP and save to store
func (w *WordPress) Import(r io.Reader, siteID string) (size int, err error) {
if e := w.DataStore.DeleteAll(siteID); e != nil {
return 0, e
}
@@ -76,9 +76,9 @@ func (w *WordPress) Import(r io.Reader, siteID string) (size int, err error) {
}
if failed > 0 {
err = errors.Errorf("failed to save %d comments", failed)
err = fmt.Errorf("failed to save %d comments", failed)
if passed == 0 {
err = errors.New("import failed")
err = fmt.Errorf("import failed")
}
}
@@ -88,7 +88,6 @@ func (w *WordPress) Import(r io.Reader, siteID string) (size int, err error) {
}
func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
decoder := xml.NewDecoder(r)
commentsCh := make(chan store.Comment)
@@ -107,8 +106,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
break
}
switch el := t.(type) {
case xml.StartElement:
if el, ok := t.(xml.StartElement); ok {
if el.Name.Local == "item" {
stats.inpItems++
item := wpItem{}
@@ -141,7 +139,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
ParentID: comment.PID,
Imported: true,
}
commentsCh <- commentFormatter.Format(c)
commentsCh <- commentFormatter.Format(c, w.DisableFancyTextFormatting)
stats.inpComments++
if stats.inpComments%1000 == 0 {
log.Printf("[DEBUG] processed %d comments", stats.inpComments)
+17 -5
View File
@@ -24,7 +24,7 @@ func TestWordPress_Import(t *testing.T) {
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
wp := WordPress{DataStore: &dataStore}
wp := WordPress{DataStore: &dataStore, DisableFancyTextFormatting: false}
size, err := wp.Import(strings.NewReader(xmlTestWP), siteID)
assert.NoError(t, err)
assert.Equal(t, 3, size)
@@ -41,7 +41,7 @@ func TestWordPress_Import(t *testing.T) {
assert.Equal(t, "e8b1e92bbcf5b9bb88472f9bdb82d1b8c7ed39d6", c.User.IP)
ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14")
assert.Equal(t, ts, c.Timestamp)
assert.Equal(t, c.Text, "<p>Mekkatorque was over in that tent up to the right</p>\n")
assert.Equal(t, "<p>«Mekkatorque» was over in that tent up to the right</p>\n", c.Text)
assert.True(t, c.Imported)
posts, err := dataStore.List(siteID, 0, 0)
@@ -54,13 +54,25 @@ func TestWordPress_Import(t *testing.T) {
count, err := dataStore.Count(store.Locator{URL: "https://realmenweardress.es/2010/07/do-you-rp/", SiteID: siteID})
assert.NoError(t, err)
assert.Equal(t, 3, count)
// test with DisableFancyTextFormatting
wp = WordPress{DataStore: &dataStore, DisableFancyTextFormatting: true}
size, err = wp.Import(strings.NewReader(xmlTestWP), siteID)
assert.NoError(t, err)
assert.Equal(t, 3, size)
last, err = dataStore.Last(siteID, 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 3, len(last), "3 comments imported")
assert.Equal(t, "<p>&#34;Mekkatorque&#34; was over in that tent up to the right</p>\n", last[0].Text)
}
func TestWordPress_Convert(t *testing.T) {
wp := WordPress{}
ch := wp.convert(strings.NewReader(xmlTestWP), "testWP")
comments := []store.Comment{}
comments := make([]store.Comment, 0, 3)
for c := range ch {
comments = append(comments, c)
}
@@ -88,7 +100,7 @@ func TestWP_Convert_MD(t *testing.T) {
wp := WordPress{}
ch := wp.convert(strings.NewReader(xmlTestWPmd), "siteID")
comments := []store.Comment{}
comments := make([]store.Comment, 0, 3)
for c := range ch {
comments = append(comments, c)
}
@@ -247,7 +259,7 @@ var xmlTestWP = `
<wp:comment_author_IP><![CDATA[128.243.253.117]]></wp:comment_author_IP>
<wp:comment_date><![CDATA[2010-08-18 15:19:14]]></wp:comment_date>
<wp:comment_date_gmt><![CDATA[2010-08-18 15:19:14]]></wp:comment_date_gmt>
<wp:comment_content><![CDATA[Mekkatorque was over in that tent up to the right]]></wp:comment_content>
<wp:comment_content><![CDATA["Mekkatorque" was over in that tent up to the right]]></wp:comment_content>
<wp:comment_approved><![CDATA[1]]></wp:comment_approved>
<wp:comment_type><![CDATA[]]></wp:comment_type>
<wp:comment_parent>13</wp:comment_parent>
+96 -217
View File
@@ -3,20 +3,16 @@ package notify
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"mime"
"mime/quotedprintable"
"net"
"net/smtp"
"text/template"
"html/template"
"net/url"
"time"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
ntf "github.com/go-pkgz/notify"
"github.com/go-pkgz/repeater/v2"
"github.com/microcosm-cc/bluemonday"
"github.com/umputun/remark42/backend/app/templates"
)
@@ -31,63 +27,28 @@ type EmailParams struct {
SubscribeURL string // full subscribe handler URL
UnsubscribeURL string // full unsubscribe handler URL
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
}
// SMTPParams contain settings for smtp server connection
type SMTPParams struct {
Host string // SMTP host
Port int // SMTP port
TLS bool // TLS auth
Username string // user name
Password string // password
TimeOut time.Duration // TCP connection timeout
TokenGenFn func(userID, email, site string) (string, error) // unsubscribe token generation function
}
// Email implements notify.Destination for email
type Email struct {
EmailParams
SMTPParams
*ntf.Email
smtp smtpClientCreator
EmailParams
msgTmpl *template.Template // parsed request message template
verifyTmpl *template.Template // parsed verification message template
}
// default email client implementation
type emailClient struct{ smtpClientCreator }
// smtpClient interface defines subset of net/smtp used by email client
type smtpClient interface {
Mail(string) error
Auth(smtp.Auth) error
Rcpt(string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}
// smtpClientCreator interface defines function for creating new smtpClients
type smtpClientCreator interface {
Create(SMTPParams) (smtpClient, error)
}
type emailMessage struct {
from string
to string
message string
}
// msgTmplData store data for message from request template execution
type msgTmplData struct {
UserName string
UserPicture string
CommentText string
CommentText template.HTML
CommentLink string
CommentDate time.Time
ParentUserName string
ParentUserPicture string
ParentCommentText string
ParentCommentText template.HTML
ParentCommentLink string
ParentCommentDate time.Time
PostTitle string
@@ -96,6 +57,30 @@ type msgTmplData struct {
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
type verifyTmplData struct {
User string
@@ -113,15 +98,14 @@ const (
)
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
func NewEmail(emailParams EmailParams, smtpParams SMTPParams) (*Email, error) {
func NewEmail(emailParams EmailParams, smtpParams ntf.SMTPParams) (*Email, error) {
// set up Email emailParams
res := Email{EmailParams: emailParams}
res.smtp = &emailClient{}
res.SMTPParams = smtpParams
if res.TimeOut <= 0 {
res.TimeOut = defaultEmailTimeout
if smtpParams.TimeOut <= 0 {
smtpParams.TimeOut = defaultEmailTimeout
}
res := Email{Email: ntf.NewEmail(smtpParams), EmailParams: emailParams}
if res.VerificationSubject == "" {
res.VerificationSubject = defaultVerificationSubject
}
@@ -129,7 +113,7 @@ func NewEmail(emailParams EmailParams, smtpParams SMTPParams) (*Email, error) {
// initialize templates
err := res.setTemplates()
if err != nil {
return nil, errors.Wrap(err, "can't set templates")
return nil, fmt.Errorf("can't set templates: %w", err)
}
log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
@@ -141,7 +125,6 @@ func NewEmail(emailParams EmailParams, smtpParams SMTPParams) (*Email, error) {
func (e *Email) setTemplates() error {
var err error
var msgTmplFile, verifyTmplFile []byte
fs := templates.NewFS()
if e.VerificationTemplatePath == "" {
e.VerificationTemplatePath = defaultEmailVerificationTemplatePath
@@ -151,17 +134,17 @@ func (e *Email) setTemplates() error {
e.MsgTemplatePath = defaultEmailTemplatePath
}
if msgTmplFile, err = fs.ReadFile(e.MsgTemplatePath); err != nil {
return errors.Wrapf(err, "can't read message template")
if msgTmplFile, err = templates.Read(e.MsgTemplatePath); err != nil {
return fmt.Errorf("can't read message template: %w", err)
}
if verifyTmplFile, err = fs.ReadFile(e.VerificationTemplatePath); err != nil {
return errors.Wrapf(err, "can't read verification template")
if verifyTmplFile, err = templates.Read(e.VerificationTemplatePath); err != nil {
return fmt.Errorf("can't read verification template: %w", err)
}
if e.msgTmpl, err = template.New("msgTmpl").Parse(string(msgTmplFile)); err != nil {
return errors.Wrapf(err, "can't parse message template")
return fmt.Errorf("can't parse message template: %w", err)
}
if e.verifyTmpl, err = template.New("verifyTmpl").Parse(string(verifyTmplFile)); err != nil {
return errors.Wrapf(err, "can't parse verification template")
return fmt.Errorf("can't parse verification template: %w", err)
}
return nil
@@ -173,23 +156,27 @@ func (e *Email) setTemplates() error {
func (e *Email) Send(ctx context.Context, req Request) error {
select {
case <-ctx.Done():
return errors.Errorf("sending email messages about comment %q aborted due to canceled context", req.Comment.ID)
return fmt.Errorf("sending email messages about comment %q aborted due to canceled context", req.Comment.ID)
default:
}
result := new(multierror.Error)
var errs []error
for _, email := range req.Emails {
err := e.buildAndSendMessage(ctx, req, email, false)
result = multierror.Append(errors.Wrapf(err, "problem sending user email notification to %q", email))
if err != nil {
errs = append(errs, fmt.Errorf("problem sending user email notification to %q: %w", email, err))
}
}
for _, email := range e.AdminEmails {
err := e.buildAndSendMessage(ctx, req, email, true)
result = multierror.Append(errors.Wrapf(err, "problem sending admin email notification to %q", email))
if err != nil {
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 {
@@ -199,10 +186,19 @@ func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email stri
return err
}
return repeater.NewDefault(5, time.Millisecond*250).Do(
return repeater.NewFixed(5, time.Millisecond*250).Do(
ctx,
func() error {
return e.sendMessage(emailMessage{from: e.From, to: email, message: msg})
return e.Email.Send(
ctx,
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
email,
url.QueryEscape(e.From),
url.QueryEscape(msg.unsubscribeLink),
url.QueryEscape(msg.subject),
),
msg.body,
)
})
}
@@ -215,7 +211,7 @@ func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) e
}
select {
case <-ctx.Done():
return errors.Errorf("sending message to %q aborted due to canceled context", req.User)
return fmt.Errorf("sending message to %q aborted due to canceled context", req.User)
default:
}
@@ -225,16 +221,23 @@ func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) e
return err
}
return repeater.NewDefault(5, time.Millisecond*250).Do(
return repeater.NewFixed(5, time.Millisecond*250).Do(
ctx,
func() error {
return e.sendMessage(emailMessage{from: e.From, to: req.Email, message: msg})
return e.Email.Send(
ctx,
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
req.Email,
url.QueryEscape(e.From),
url.QueryEscape(e.VerificationSubject),
),
msg,
)
})
}
// buildVerificationMessage generates verification email message based on given input
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
subject := e.VerificationSubject
msg := bytes.Buffer{}
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
User: user,
@@ -244,13 +247,19 @@ func (e *Email) buildVerificationMessage(user, email, token, site string) (strin
SubscribeURL: e.SubscribeURL,
})
if err != nil {
return "", errors.Wrapf(err, "error executing template to build verification message")
return "", fmt.Errorf("error executing template to build verification message: %w", err)
}
return e.buildMessage(subject, msg.String(), email, "text/html", "")
return msg.String(), nil
}
type commentMessage struct {
subject string
body string
unsubscribeLink string
}
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (string, error) {
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (commentMessage, error) {
subject := "New reply to your comment"
if forAdmin {
subject = "New comment to your site"
@@ -261,7 +270,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
token, err := e.TokenGenFn(req.parent.User.ID, email, req.Comment.Locator.SiteID)
if err != nil {
return "", errors.Wrapf(err, "error creating token for unsubscribe link")
return commentMessage{}, fmt.Errorf("error creating token for unsubscribe link: %w", err)
}
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
if forAdmin {
@@ -273,7 +282,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
tmplData := msgTmplData{
UserName: req.Comment.User.Name,
UserPicture: req.Comment.User.Picture,
CommentText: req.Comment.Text,
CommentText: emailSafeHTML(req.Comment.Text),
CommentLink: commentURLPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp,
PostTitle: req.Comment.PostTitle,
@@ -285,147 +294,17 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
if req.Comment.ParentID != "" {
tmplData.ParentUserName = req.parent.User.Name
tmplData.ParentUserPicture = req.parent.User.Picture
tmplData.ParentCommentText = req.parent.Text
tmplData.ParentCommentText = emailSafeHTML(req.parent.Text)
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
tmplData.ParentCommentDate = req.parent.Timestamp
}
err = e.msgTmpl.Execute(&msg, tmplData)
if err != nil {
return "", errors.Wrapf(err, "error executing template to build comment reply message")
return commentMessage{}, fmt.Errorf("error executing template to build comment reply message: %w", err)
}
return e.buildMessage(subject, msg.String(), email, "text/html", unsubscribeLink)
}
// buildMessage generates email message to send using net/smtp.Data()
func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink string) (message string, err error) {
addHeader := func(msg, h, v string) string {
msg += fmt.Sprintf("%s: %s\n", h, v)
return msg
}
message = addHeader(message, "From", e.From)
message = addHeader(message, "To", to)
message = addHeader(message, "Subject", mime.BEncoding.Encode("utf-8", subject))
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
if contentType != "" {
message = addHeader(message, "MIME-version", "1.0")
message = addHeader(message, "Content-Type", contentType+`; charset="UTF-8"`)
}
if unsubscribeLink != "" {
// https://support.google.com/mail/answer/81126 -> "Include option to unsubscribe"
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
message = addHeader(message, "List-Unsubscribe", "<"+unsubscribeLink+">")
}
message = addHeader(message, "Date", time.Now().Format(time.RFC1123Z))
buff := &bytes.Buffer{}
qp := quotedprintable.NewWriter(buff)
if _, err := qp.Write([]byte(body)); err != nil {
return "", err
}
// flush now, must NOT use defer, for small body, defer may cause buff.String() got empty body
if err := qp.Close(); err != nil {
return "", fmt.Errorf("quotedprintable Write failed: %w", err)
}
m := buff.String()
message += "\n" + m
return message, nil
}
// sendMessage sends messages to server in a new connection, closing the connection after finishing.
// Thread safe.
func (e *Email) sendMessage(m emailMessage) error {
if e.smtp == nil {
return errors.New("sendMessage called without client set")
}
client, err := e.smtp.Create(e.SMTPParams)
if err != nil {
return errors.Wrap(err, "failed to make smtp Create")
}
defer func() {
if err = client.Quit(); err != nil {
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
if err = client.Close(); err != nil {
log.Printf("[WARN] can't close smtp connection, %v", err)
}
}
}()
if err = client.Mail(m.from); err != nil {
return errors.Wrapf(err, "bad from address %q", m.from)
}
if err = client.Rcpt(m.to); err != nil {
return errors.Wrapf(err, "bad to address %q", m.to)
}
writer, err := client.Data()
if err != nil {
return errors.Wrap(err, "can't make email writer")
}
defer func() {
if err = writer.Close(); err != nil {
log.Printf("[WARN] can't close smtp body writer, %v", err)
}
}()
buf := bytes.NewBufferString(m.message)
if _, err = buf.WriteTo(writer); err != nil {
return errors.Wrapf(err, "failed to send email body to %q", m.to)
}
return nil
}
// String representation of Email object
func (e *Email) String() string {
return fmt.Sprintf("email: from %q with username '%s' at server %s:%d", e.From, e.Username, e.Host, e.Port)
}
// Create establish SMTP connection with server using credentials in smtpClientWithCreator.SMTPParams
// and returns pointer to it. Thread safe.
func (s *emailClient) Create(params SMTPParams) (smtpClient, error) {
authenticate := func(c *smtp.Client) error {
if params.Username == "" || params.Password == "" {
return nil
}
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
if err := c.Auth(auth); err != nil {
return errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
}
return nil
}
var c *smtp.Client
srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
if params.TLS {
tlsConf := &tls.Config{
InsecureSkipVerify: false,
ServerName: params.Host,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", srvAddress, tlsConf)
if err != nil {
return nil, errors.Wrapf(err, "failed to dial smtp tls to %s", srvAddress)
}
if c, err = smtp.NewClient(conn, params.Host); err != nil {
return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress)
}
return c, authenticate(c)
}
conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut)
if err != nil {
return nil, errors.Wrapf(err, "timeout connecting to %s", srvAddress)
}
c, err = smtp.NewClient(conn, params.Host)
if err != nil {
return nil, errors.Wrap(err, "failed to dial")
}
return c, authenticate(c)
return commentMessage{
subject: subject,
body: msg.String(),
unsubscribeLink: unsubscribeLink,
}, err
}
+103 -255
View File
@@ -1,16 +1,12 @@
package notify
import (
"bytes"
"context"
"errors"
"io"
"net/smtp"
"sync"
"fmt"
"html/template"
"testing"
"text/template"
"time"
ntf "github.com/go-pkgz/notify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -23,13 +19,13 @@ func TestEmailNew(t *testing.T) {
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}
smtpParams := SMTPParams{
smtpParams := ntf.SMTPParams{
Host: "test@host",
Port: 1000,
TLS: true,
StartTLS: true,
Username: "test@username",
Password: "test@password",
TimeOut: time.Second,
}
email, err := NewEmail(emailParams, smtpParams)
@@ -38,7 +34,7 @@ func TestEmailNew(t *testing.T) {
assert.NotNil(t, email, "email returned")
assert.NotNil(t, email.msgTmpl, "e.template is set")
assert.Equal(t, emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
assert.Equal(t, emailParams.From, email.From, "emailParams.From unchanged after creation")
if smtpParams.TimeOut == 0 {
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
} else {
@@ -49,6 +45,8 @@ func TestEmailNew(t *testing.T) {
assert.Equal(t, smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
assert.Equal(t, smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
assert.Equal(t, smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
assert.Equal(t, smtpParams.StartTLS, email.StartTLS, "emailParams.TLS unchanged after creation")
assert.Equal(t, "email: with username 'test@username' at server test@host:1000 with TLS with StartTLS", email.String())
}
func Test_initTemplatesErr(t *testing.T) {
@@ -59,18 +57,16 @@ func Test_initTemplatesErr(t *testing.T) {
}{
{
name: "with wrong path to verification template",
errText: "can't read verification template: open notfount.tmpl: no such file or directory",
errText: "notfound.tmpl: file does not exist",
emailParams: EmailParams{
VerificationTemplatePath: "notfount.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
VerificationTemplatePath: "notfound.tmpl",
},
},
{
name: "with wrong path to message template",
errText: "can't read message template: open notfount.tmpl: no such file or directory",
errText: "notfound.tmpl: file does not exist",
emailParams: EmailParams{
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "notfount.tmpl",
MsgTemplatePath: "notfound.tmpl",
},
},
{
@@ -92,11 +88,10 @@ func Test_initTemplatesErr(t *testing.T) {
}
for _, d := range testSet {
d := d
t.Run(d.name, func(t *testing.T) {
e := Email{EmailParams: d.emailParams}
err := e.setTemplates()
e, err := NewEmail(d.emailParams, ntf.SMTPParams{})
require.Error(t, err)
require.Nil(t, e)
assert.Contains(t, err.Error(), d.errText)
})
}
@@ -115,27 +110,32 @@ func TestEmailSendErrors(t *testing.T) {
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
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"}}),
"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: "+
"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())
cancel()
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
"sending email messages about comment \"999\" aborted due to canceled context")
e.smtp = &fakeTestSMTP{}
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\":"+
" error creating token for unsubscribe link: token generation error\n\n")
"problem sending user email notification to \"bad@example.org\":"+
" 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) {
email, err := NewEmail(EmailParams{
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
}, ntf.SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email, "expecting email returned")
// prevent triggering e.autoFlush creation
@@ -144,67 +144,14 @@ func TestEmailSend_ExitConditions(t *testing.T) {
"Message without Emails and AdminEmails is not sent and returns nil")
}
func TestEmailSendClientError(t *testing.T) {
var testSet = []struct {
name string
smtp *fakeTestSMTP
err string
}{
{name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}},
err: "bad from address \"\": failed to verify sender"},
{name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}},
err: "bad to address \"\": failed to verify receiver"},
{name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}}},
{name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}},
err: "can't make email writer: failed to send"},
}
for _, d := range testSet {
d := d
t.Run(d.name, func(t *testing.T) {
e := Email{smtp: d.smtp}
if d.err != "" {
assert.EqualError(t, e.sendMessage(emailMessage{}), d.err,
"expected error for e.sendMessage")
} else {
assert.NoError(t, e.sendMessage(emailMessage{}),
"expected no error for e.sendMessage")
}
})
}
e := Email{}
e.smtp = nil
assert.Error(t, e.sendMessage(emailMessage{}),
"nil e.smtp should return error")
e.smtp = &fakeTestSMTP{}
assert.NoError(t, e.sendMessage(emailMessage{}), "",
"no error expected for e.sendMessage in normal flow")
e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}}
assert.NoError(t, e.sendMessage(emailMessage{}), "",
"no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close")
e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}}
assert.EqualError(t, e.sendMessage(emailMessage{}), "failed to make smtp Create: failed to create client",
"e.send called without smtpClient set returns error")
}
func TestEmail_DefaultTemplates(t *testing.T) {
email, err := NewEmail(EmailParams{}, SMTPParams{})
assert.Error(t, err)
assert.Nil(t, email)
email, err = NewEmail(EmailParams{VerificationTemplatePath: "testdata/verification.html.tmpl"}, SMTPParams{})
assert.Error(t, err)
assert.Nil(t, email)
}
func TestEmail_Send(t *testing.T) {
email, err := NewEmail(EmailParams{
From: "from@example.org",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
}, ntf.SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSMTP := fakeTestSMTP{}
email.smtp = &fakeSMTP
email.TokenGenFn = TokenGenFn
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
req := Request{
@@ -212,22 +159,21 @@ func TestEmail_Send(t *testing.T) {
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
Emails: []string{"test@example.org"},
}
assert.NoError(t, email.Send(context.TODO(), req))
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
assert.Equal(t, 1, fakeSMTP.readQuitCount())
assert.Equal(t, "test@example.org", fakeSMTP.readRcpt())
assert.Contains(t, email.Send(context.Background(), req).Error(), "problem sending user email notification to \"test@example.org\"")
// test buildMessageFromRequest separately for message text
res, err := email.buildMessageFromRequest(req, req.Emails[0], false)
msg, err := email.buildMessageFromRequest(req, req.Emails[0], false)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
Subject: New reply to your comment for "test_title"
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
List-Unsubscribe-Post: List-Unsubscribe=One-Click
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
Date: `)
assert.Equal(t, `
New reply from test_user on your comment to «test_title»
User: test_user
01.01.0001 at 00:00
Comment:
test@example.org for parent_user
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&amp;tkn=token
`, msg.body)
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)
// send email to both user and admin, without parent set
email.AdminEmails = []string{"admin@example.org"}
@@ -235,51 +181,63 @@ Date: `)
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
Emails: []string{"test@example.org"},
}
assert.NoError(t, email.Send(context.TODO(), req))
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
assert.Equal(t, 3, fakeSMTP.readQuitCount(), "plus two emails: one for user and one for admin")
assert.Equal(t, "admin@example.org", fakeSMTP.readRcpt())
res, err = email.buildMessageFromRequest(req, email.AdminEmails[0], true)
assert.Error(t, email.Send(context.Background(), req))
msg, err = email.buildMessageFromRequest(req, email.AdminEmails[0], true)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: admin@example.org
Subject: New comment to your site for "test_title"
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
assert.Equal(t, `
New comment from test_user on your site to «test_title»
User: test_user
01.01.0001 at 00:00
Comment:
admin@example.org
`, msg.body)
assert.Equal(t, `New comment to your site for "test_title"`, msg.subject)
assert.Empty(t, msg.unsubscribeLink)
}
func TestEmail_SendWithUnicodeInSubject(t *testing.T) {
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",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSMTP := fakeTestSMTP{}
email.smtp = &fakeSMTP
From: "from@example.org",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, ntf.SMTPParams{})
require.NoError(t, err)
email.TokenGenFn = TokenGenFn
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
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"}, ParentID: "1", PostTitle: "Привет"},
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title", Text: malicious},
Emails: []string{"test@example.org"},
}
// test buildMessageFromRequest separately for message text
res, err := email.buildMessageFromRequest(req, req.Emails[0], false)
assert.NoError(t, err)
// `=?utf-8?b?TmV3IHJlcGx5IHRvIHlvdXIgY29tbWVudCBmb3IgItCf0YDQuNCy0LXRgiI=?=` -> `New reply to your comment for "Привет"` in base64 + required prefix and suffix
assert.Contains(t, res, `From: from@example.org
To: test@example.org
Subject: =?utf-8?b?TmV3IHJlcGx5IHRvIHlvdXIgY29tbWVudCBmb3IgItCf0YDQuNCy0LXRgiI=?=
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
List-Unsubscribe-Post: List-Unsubscribe=One-Click
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
Date: `)
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) {
@@ -287,11 +245,9 @@ func TestEmail_SendVerification(t *testing.T) {
From: "from@example.org",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
}, ntf.SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSMTP := fakeTestSMTP{}
email.smtp = &fakeSMTP
email.TokenGenFn = TokenGenFn
// proper VerificationRequest without email
req := VerificationRequest{
@@ -299,149 +255,41 @@ func TestEmail_SendVerification(t *testing.T) {
User: "test_username",
Token: "secret_",
}
assert.NoError(t, email.SendVerification(context.TODO(), req))
assert.Equal(t, "", fakeSMTP.readMail())
assert.Equal(t, 0, fakeSMTP.readQuitCount())
assert.Equal(t, "", fakeSMTP.readRcpt())
assert.NoError(t, email.SendVerification(context.Background(), req))
// proper VerificationRequest with email
req.Email = "test@example.org"
assert.NoError(t, email.SendVerification(context.TODO(), req))
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
assert.Equal(t, 1, fakeSMTP.readQuitCount())
assert.Equal(t, "test@example.org", fakeSMTP.readRcpt())
assert.Error(t, email.SendVerification(context.Background(), req), "failed to make smtp client")
// VerificationRequest with canceled context
ctx, cancel := context.WithCancel(context.TODO())
ctx, cancel := context.WithCancel(context.Background())
cancel()
assert.EqualError(t, email.SendVerification(ctx, req), "sending message to \"test_username\" aborted due to canceled context")
// test buildVerificationMessage separately for message text
res, err := email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
Subject: Email verification
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
assert.Equal(t, res, `Confirmation for test_username on site remark
Token:secret_
Sent to test@example.org
`)
assert.Contains(t, res, `secret_`)
assert.NotContains(t, res, `https://example.org/`)
email.SubscribeURL = "https://example.org/subscribe.html?token="
res, err = email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
Subject: Email verification
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
assert.Contains(t, res, `https://example.org/subscribe.html?token=3Dsecret_`)
}
assert.Equal(t, res, `Confirmation for test_username on site remark
Subscribe url: https://example.org/subscribe.html?token=secret_
Token:secret_
Sent to test@example.org
func Test_emailClient_Create(t *testing.T) {
creator := emailClient{}
client, err := creator.Create(SMTPParams{})
assert.Error(t, err, "absence of address to connect results in error")
assert.Nil(t, client, "no client returned in case of error")
}
type fakeTestSMTP struct {
fail map[string]bool
buff bytes.Buffer
mail, rcpt string
auth bool
close bool
quitCount int
lock sync.RWMutex
}
func (f *fakeTestSMTP) Create(SMTPParams) (smtpClient, error) {
if f.fail["create"] {
return nil, errors.New("failed to create client")
}
return f, nil
}
func (f *fakeTestSMTP) Auth(smtp.Auth) error { f.auth = true; return nil }
func (f *fakeTestSMTP) Mail(m string) error {
f.lock.Lock()
f.mail = m
f.lock.Unlock()
if f.fail["mail"] {
return errors.New("failed to verify sender")
}
return nil
}
func (f *fakeTestSMTP) Rcpt(r string) error {
f.lock.Lock()
f.rcpt = r
f.lock.Unlock()
if f.fail["rcpt"] {
return errors.New("failed to verify receiver")
}
return nil
}
func (f *fakeTestSMTP) Quit() error {
f.lock.Lock()
f.quitCount++
f.lock.Unlock()
if f.fail["quit"] {
return errors.New("failed to quit")
}
return nil
}
func (f *fakeTestSMTP) Close() error {
f.close = true
if f.fail["close"] {
return errors.New("failed to close")
}
return nil
}
func (f *fakeTestSMTP) Data() (io.WriteCloser, error) {
if f.fail["data"] {
return nil, errors.New("failed to send")
}
return nopCloser{&f.buff}, nil
}
func (f *fakeTestSMTP) readRcpt() string {
f.lock.RLock()
defer f.lock.RUnlock()
return f.rcpt
}
func (f *fakeTestSMTP) readMail() string {
f.lock.RLock()
defer f.lock.RUnlock()
return f.mail
}
func (f *fakeTestSMTP) readQuitCount() int {
f.lock.RLock()
defer f.lock.RUnlock()
return f.quitCount
`)
}
func TokenGenFn(user, _, _ string) (string, error) {
if user == "error" {
return "", errors.New("token generation error")
return "", fmt.Errorf("token generation error")
}
return "token", nil
}
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error {
return nil
}
+37 -18
View File
@@ -19,7 +19,7 @@ type Service struct {
queue chan Request
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
cancel context.CancelFunc
}
@@ -34,14 +34,19 @@ type Destination interface {
// Store defines the minimal interface accessing stored comments used by notifier
type Store interface {
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
GetUserEmail(siteID string, userID string) (string, error)
GetUserEmail(siteID, userID string) (string, error)
GetUserTelegram(siteID, userID string) (string, error)
}
// used for email and telegram retrieval from user details
type getUserDetail func(string, string) (string, error)
// Request notification for a Comment
type Request struct {
Comment store.Comment
parent store.Comment
Emails []string
Comment store.Comment
parent store.Comment
Emails []string
Telegrams []string
}
// VerificationRequest notification for user
@@ -78,13 +83,14 @@ func NewService(dataService Store, size int, destinations ...Destination) *Servi
// Submit Request to internal channel if not busy, drop if can't send
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
}
if s.dataService != nil && req.Comment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil {
req.parent = p
req.Emails = deduplicateStrings(s.getNotificationEmails(req, p))
req.Emails = s.getNotificationTargets(req, p, s.dataService.GetUserEmail)
req.Telegrams = s.getNotificationTargets(req, p, s.dataService.GetUserTelegram)
}
}
select {
@@ -94,30 +100,37 @@ func (s *Service) Submit(req Request) {
}
}
// getNotificationEmails returns list of emails for notifications for provided comment.
// Emails is not added to the returned list in case original message is from the same user as the notification receiver.
func (s *Service) getNotificationEmails(req Request, notifyComment store.Comment) (result []string) {
// getNotificationTargets returns list of notification targets (like email or telegram username) for users
// interested in notifications for provided comment.
// Targets are not added to the returned list in case the original message
// is from the same user as the notification receiver.
// Results are deduplicated.
func (s *Service) getNotificationTargets(
req Request,
notifyComment store.Comment,
getUserDetail getUserDetail,
) (result []string) {
// add current user email only if the user is not the one who wrote the original comment
if notifyComment.User.ID != req.Comment.User.ID {
email, err := s.dataService.GetUserEmail(req.Comment.Locator.SiteID, notifyComment.User.ID)
detail, err := getUserDetail(req.Comment.Locator.SiteID, notifyComment.User.ID)
if err != nil {
log.Printf("[WARN] can't read email for %s, %v", notifyComment.User.ID, err)
log.Printf("[WARN] can't read notification detail for %s, %v", notifyComment.User.ID, err)
}
if email != "" {
result = append(result, email)
if detail != "" {
result = append(result, detail)
}
}
if notifyComment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, notifyComment.ParentID, store.User{}); err == nil {
result = append(result, s.getNotificationEmails(req, p)...)
result = append(result, s.getNotificationTargets(req, p, getUserDetail)...)
}
}
return result
return deduplicateStrings(result)
}
// SubmitVerification to internal channel if not busy, drop if can't send
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
}
select {
@@ -130,13 +143,19 @@ func (s *Service) SubmitVerification(req VerificationRequest) {
// Close queue channel and wait for completion
func (s *Service) Close() {
if s.queue != nil {
// don't panic in case service is already closed
select {
case <-s.ctx.Done():
return
default:
}
log.Print("[DEBUG] close notifier")
close(s.queue)
close(s.verificationQueue)
s.cancel()
<-s.ctx.Done()
}
atomic.StoreUint32(&s.closed, 1)
s.closed.Store(1)
}
func (s *Service) do() {
+27 -12
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"sync"
"time"
log "github.com/go-pkgz/lgr"
)
@@ -16,35 +15,40 @@ type MockDest struct {
id int
closed bool
lock sync.Mutex
block chan struct{} // if non-nil, Send/SendVerification wait on it before recording, letting tests pin the consumer
}
// Send mock
func (m *MockDest) Send(ctx context.Context, r Request) error {
if m.block != nil {
<-m.block
}
m.lock.Lock()
defer m.lock.Unlock()
select {
case <-time.After(10 * time.Millisecond):
m.data = append(m.data, r)
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
case <-ctx.Done():
if err := ctx.Err(); err != nil {
log.Printf("ctx closed %d", m.id)
m.closed = true
return nil
}
m.data = append(m.data, r)
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
return nil
}
// SendVerification mock
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
if m.block != nil {
<-m.block
}
m.lock.Lock()
defer m.lock.Unlock()
select {
case <-time.After(10 * time.Millisecond):
m.verificationData = append(m.verificationData, v)
log.Printf("sent verification %s -> %d", v.User, m.id)
case <-ctx.Done():
if err := ctx.Err(); err != nil {
log.Printf("verification ctx closed %d", m.id)
m.closed = true
return nil
}
m.verificationData = append(m.verificationData, v)
log.Printf("sent verification %s -> %d", v.User, m.id)
return nil
}
@@ -66,4 +70,15 @@ func (m *MockDest) GetVerify() []VerificationRequest {
return res
}
func (m *MockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
// IsClosed returns closed status safely
func (m *MockDest) IsClosed() bool {
m.lock.Lock()
defer m.lock.Unlock()
return m.closed
}
func (m *MockDest) String() string {
m.lock.Lock()
defer m.lock.Unlock()
return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed)
}
+231 -199
View File
@@ -1,12 +1,9 @@
package notify
import (
"errors"
"fmt"
"math/rand"
"sync/atomic"
"testing"
"time"
"testing/synctest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -22,277 +19,312 @@ func TestService_NoDestinations(t *testing.T) {
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Close()
// second call should not result in panic
s.Close()
}
func TestService_WithDestinations(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
synctest.Test(t, func(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "100"}})
time.Sleep(time.Millisecond * 110)
s.Submit(Request{Comment: store.Comment{ID: "101"}})
time.Sleep(time.Millisecond * 110)
s.Submit(Request{Comment: store.Comment{ID: "102"}})
time.Sleep(time.Millisecond * 110)
s.Close()
s.Submit(Request{Comment: store.Comment{ID: "100"}})
synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "101"}})
synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "102"}})
synctest.Wait()
s.Close()
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
})
}
func TestService_WithDrops(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
synctest.Test(t, func(t *testing.T) {
// 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)
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "100"}})
s.Submit(Request{Comment: store.Comment{ID: "101"}})
s.Submit(Request{Comment: store.Comment{ID: "102"}})
time.Sleep(time.Millisecond * 21)
s.Close()
s.Submit(Request{Comment: store.Comment{ID: "100"}}) // consumed, consumer blocks in Send on the gate
synctest.Wait()
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()
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
close(gate) // release the consumer: it finishes 100 then processes 101
synctest.Wait()
s.Close()
assert.LessOrEqual(t, len(d1.Get()), 2, "at least one comment from 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())
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
require.Len(t, d1.Get(), 2, "one comment of three dropped from d1, got: %v", d1.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) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
synctest.Test(t, func(t *testing.T) {
// 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)
assert.NotNil(t, s)
s.SubmitVerification(VerificationRequest{
SiteID: "remark",
User: "testUser",
Email: "test@example.org",
Token: "testToken",
s.SubmitVerification(VerificationRequest{
SiteID: "remark",
User: "testUser",
Email: "test@example.org",
Token: "testToken",
}) // consumed, consumer blocks in SendVerification on the gate
synctest.Wait()
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()
s.Close()
s.SubmitVerification(VerificationRequest{}) // safe to send after close
require.Len(t, d2.GetVerify(), 2, "one request of three dropped from d2, got: %v", d2.GetVerify())
verifyDest := d1.GetVerify()
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, "testUser", verifyDest[0].User)
assert.Equal(t, "test@example.org", verifyDest[0].Email)
assert.Equal(t, "testToken", verifyDest[0].Token)
assert.Equal(t, "second", verifyDest[1].User)
})
s.SubmitVerification(VerificationRequest{})
s.SubmitVerification(VerificationRequest{})
time.Sleep(time.Millisecond * 21)
s.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())
verifyDest := d1.GetVerify()
require.LessOrEqual(t, len(verifyDest), 2, "one request from three dropped from d1, got: %v", verifyDest)
assert.Equal(t, "remark", verifyDest[0].SiteID)
assert.Equal(t, "testUser", verifyDest[0].User)
assert.Equal(t, "test@example.org", verifyDest[0].Email)
assert.Equal(t, "testToken", verifyDest[0].Token)
}
func TestService_Many(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 5, d1, d2)
assert.NotNil(t, s)
synctest.Test(t, func(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 5, d1, d2)
assert.NotNil(t, s)
for i := 0; i < 10; i++ {
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
s.SubmitVerification(VerificationRequest{User: fmt.Sprintf("%d", 100+i)})
time.Sleep(time.Millisecond * time.Duration(rand.Int31n(20)))
}
s.Close()
time.Sleep(time.Millisecond * 10)
for i := range 10 {
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
s.SubmitVerification(VerificationRequest{User: fmt.Sprintf("%d", 100+i)})
}
s.Close()
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1")
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2")
assert.True(t, d1.closed)
assert.True(t, d2.closed)
assert.Equal(t, "mock id=1, closed=true", d1.String())
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1")
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2")
})
}
func TestService_WithParent(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}}
synctest.Test(t, func(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}}
dataStore.data["p1"] = store.Comment{ID: "p1"}
dataStore.data["p2"] = store.Comment{ID: "p2"}
dataStore.data["p1"] = store.Comment{ID: "p1"}
dataStore.data["p2"] = store.Comment{ID: "p2"}
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
time.Sleep(time.Millisecond * 110)
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
time.Sleep(time.Millisecond * 110)
s.Close()
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
synctest.Wait()
s.Close()
destRes := dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
assert.Equal(t, "p1", destRes[0].parent.ID)
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
assert.Equal(t, "", destRes[1].parent.ID)
destRes := dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
assert.Equal(t, "p1", destRes[0].parent.ID)
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
assert.Equal(t, "", destRes[1].parent.ID)
})
}
func TestService_EmailRetrieval(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, emailData: map[string]string{}}
synctest.Test(t, func(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.emailData["u1"] = "u1@example.com"
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.userDetails["u1"] = "u1@example.com"
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
// one comment, one notification
s.Submit(Request{Comment: dataStore.data["p1"]})
time.Sleep(time.Millisecond * 110)
// one comment, one notification
s.Submit(Request{Comment: dataStore.data["p1"]})
synctest.Wait()
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
// reply to the first comment, same comment as one in original comment
s.Submit(Request{Comment: dataStore.data["p2"]})
time.Sleep(time.Millisecond * 110)
// reply to the first comment, same comment as one in original comment
s.Submit(Request{Comment: dataStore.data["p2"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.Empty(t, destRes[1].Emails, "u1 is not notified they are the one who left the comment")
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.Empty(t, destRes[1].Emails, "u1 is not notified they are the one who left the comment")
// another reply to the first comment, another user
s.Submit(Request{Comment: dataStore.data["p3"]})
time.Sleep(time.Millisecond * 110)
// another reply to the first comment, another user
s.Submit(Request{Comment: dataStore.data["p3"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p1", destRes[2].parent.ID)
assert.Equal(t, "u1", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p1", destRes[2].parent.ID)
assert.Equal(t, "u1", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
// reply to the last comment by another user, should trigger email retrieval error
s.Submit(Request{Comment: dataStore.data["p4"]})
time.Sleep(time.Millisecond * 110)
// reply to the last comment by another user, should trigger email retrieval error
s.Submit(Request{Comment: dataStore.data["p4"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u2", destRes[3].parent.User.ID)
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u2", destRes[3].parent.User.ID)
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
s.Close()
s.Close()
})
}
func TestService_Recursive(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, emailData: map[string]string{}}
synctest.Test(t, func(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
dataStore.emailData["u1"] = "u1@example.com"
// second comment goes without email address for notification
dataStore.emailData["u3"] = "u3@example.com"
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
dataStore.userDetails["u1"] = "u1@example.com"
// second comment goes without email address for notification
dataStore.userDetails["u3"] = "u3@example.com"
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
// one comment from u1 with email set
s.Submit(Request{Comment: dataStore.data["p1"]})
time.Sleep(time.Millisecond * 110)
// one comment from u1 with email set
s.Submit(Request{Comment: dataStore.data["p1"]})
synctest.Wait()
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
// reply to the first comment from u2 without email set
s.Submit(Request{Comment: dataStore.data["p2"]})
time.Sleep(time.Millisecond * 110)
// reply to the first comment from u2 without email set
s.Submit(Request{Comment: dataStore.data["p2"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
// reply to the second comment from u3 with email set
s.Submit(Request{Comment: dataStore.data["p3"]})
time.Sleep(time.Millisecond * 110)
// reply to the second comment from u3 with email set
s.Submit(Request{Comment: dataStore.data["p3"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p2", destRes[2].parent.ID)
assert.Equal(t, "u2", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p2", destRes[2].parent.ID)
assert.Equal(t, "u2", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
s.Submit(Request{Comment: dataStore.data["p4"]})
time.Sleep(time.Millisecond * 110)
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
s.Submit(Request{Comment: dataStore.data["p4"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified once each")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u3", destRes[3].parent.User.ID)
assert.ElementsMatch(t, []string{"u3@example.com"}, destRes[3].Emails, "u1 is not notified they are the one who left the comment")
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified once each")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u3", destRes[3].parent.User.ID)
assert.ElementsMatch(t, []string{"u3@example.com"}, destRes[3].Emails, "u1 is not notified they are the one who left the comment")
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
s.Submit(Request{Comment: dataStore.data["p5"]})
time.Sleep(time.Millisecond * 110)
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
s.Submit(Request{Comment: dataStore.data["p5"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 5, len(destRes), "four comment notified once each")
assert.Equal(t, "p5", destRes[4].Comment.ID)
assert.Equal(t, "p4", destRes[4].parent.ID)
assert.Equal(t, "u1", destRes[4].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
destRes = dest.Get()
require.Equal(t, 5, len(destRes), "four comment notified once each")
assert.Equal(t, "p5", destRes[4].Comment.ID)
assert.Equal(t, "p4", destRes[4].parent.ID)
assert.Equal(t, "u1", destRes[4].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
s.Close()
s.Close()
})
}
func TestService_Nop(t *testing.T) {
s := NopService
s.Submit(Request{Comment: store.Comment{}})
s.Close()
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
assert.Equal(t, uint32(1), s.closed.Load())
}
type mockStore struct {
data map[string]store.Comment
emailData map[string]string
data map[string]store.Comment
userDetails map[string]string
}
func (m mockStore) getUserDetail(userID string) (string, error) {
detail, ok := m.userDetails[userID]
if !ok {
return "", fmt.Errorf("no such user")
}
return detail, nil
}
func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) {
res, ok := m.data[id]
if !ok {
return store.Comment{}, errors.New("no such id")
return store.Comment{}, fmt.Errorf("no such id")
}
return res, nil
}
func (m mockStore) GetUserEmail(_, userID string) (string, error) {
email, ok := m.emailData[userID]
if !ok {
return "", errors.New("no such user")
}
return email, nil
return m.getUserDetail(userID)
}
func (m mockStore) GetUserTelegram(_, userID string) (string, error) {
return m.getUserDetail(userID)
}
+77
View File
@@ -0,0 +1,77 @@
package notify
import (
"fmt"
"strings"
"golang.org/x/net/html"
)
// pruneHTML prunes string keeping HTML closing tags.
// maxLength applies to visible text only, not HTML tags.
func pruneHTML(htmlText string, maxLength int) string {
var result strings.Builder
var endTokens []string
visibleLen := 0
suffix := "..."
suffixLen := len(suffix)
tokenizer := html.NewTokenizer(strings.NewReader(htmlText))
for {
if tokenizer.Next() == html.ErrorToken {
return result.String()
}
token := tokenizer.Token()
switch token.Type {
case html.CommentToken, html.DoctypeToken:
continue
case html.StartTagToken:
endTokens = append([]string{fmt.Sprintf("</%s>", token.Data)}, endTokens...)
result.WriteString(token.String())
case html.EndTagToken:
if len(endTokens) > 0 {
endTokens = endTokens[1:]
}
result.WriteString(token.String())
case html.SelfClosingTagToken:
result.WriteString(token.String())
case html.TextToken:
text := token.String()
if visibleLen+len(text)+suffixLen > maxLength {
remaining := maxLength - visibleLen - suffixLen
text = pruneStringToWord(text, remaining)
result.WriteString(text)
result.WriteString(suffix)
for _, endTag := range endTokens {
result.WriteString(endTag)
}
return result.String()
}
visibleLen += len(text)
result.WriteString(text)
}
}
}
// pruneStringToWord prunes string to specified length respecting word boundaries
func pruneStringToWord(text string, maxLength int) string {
if maxLength <= 0 {
return ""
}
if len(text) <= maxLength {
return text
}
// find last space at or before maxLength to cut at word boundary
lastSpace := strings.LastIndex(text[:maxLength+1], " ")
if lastSpace <= 0 {
return ""
}
return text[:lastSpace]
}
+47
View File
@@ -0,0 +1,47 @@
package notify
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPruneHTML(t *testing.T) {
tests := []struct {
name string
html string
maxLength int
expected string
}{
{"within limit", "<p>Hello</p>", 20, "<p>Hello</p>"},
{"exceeds limit", "<p>Hello world, this is a long text</p>", 15, "<p>Hello world,...</p>"},
{"nested tags", "<div><p>Hello world</p><p>More text</p></div>", 20, "<div><p>Hello world</p><p>More...</p></div>"},
{"html comment stripped", "<!-- comment --><p>Hello</p>", 20, "<p>Hello</p>"},
{"self-closing tag", "<p>Hello<br/>World</p>", 8, "<p>Hello<br/>...</p>"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, pruneHTML(tt.html, tt.maxLength))
})
}
}
func TestPruneStringToWord(t *testing.T) {
tests := []struct {
name string
text string
maxLength int
expected string
}{
{"within limit", "hello world", 15, "hello world"},
{"cut at word boundary", "hello world and more", 11, "hello world"},
{"zero length", "hello", 0, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, pruneStringToWord(tt.text, tt.maxLength))
})
}
}
+19 -59
View File
@@ -2,43 +2,32 @@ package notify
import (
"context"
"fmt"
"net/url"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/slack-go/slack"
ntf "github.com/go-pkgz/notify"
)
// Slack implements notify.Destination for Slack
type Slack struct {
channelID string
*ntf.Slack
channelName string
client *slack.Client
}
// NewSlack makes Slack bot for notifications
func NewSlack(token, channelName string, opts ...slack.Option) (*Slack, error) {
func NewSlack(token, channelName string) *Slack {
log.Printf("[DEBUG] create new slack notifier for chan %s", channelName)
if channelName == "" {
channelName = "general"
}
client := slack.New(token, opts...)
res := &Slack{client: client, channelName: channelName}
channelID, err := res.findChannelIDByName(channelName)
if err != nil {
return nil, errors.Wrap(err, "can not find slack channel '"+channelName+"'")
}
res.channelID = channelID
log.Printf("[DEBUG] create new slack notifier for chan %s", channelID)
return res, nil
return &Slack{Slack: ntf.NewSlack(token), channelName: channelName}
}
// Send to Slack channel
func (t *Slack) Send(ctx context.Context, req Request) error {
func (s *Slack) Send(ctx context.Context, req Request) error {
log.Printf("[DEBUG] send slack notification, comment id %s", req.Comment.ID)
user := req.Comment.User.Name
@@ -51,51 +40,22 @@ func (t *Slack) Send(ctx context.Context, req Request) error {
title = "↦ " + req.Comment.PostTitle
}
_, _, err := t.client.PostMessageContext(ctx, t.channelID,
slack.MsgOptionText("New comment from "+user, false),
slack.MsgOptionAttachments(
slack.Attachment{
TitleLink: req.Comment.Locator.URL + uiNav + req.Comment.ID,
Title: title,
Text: req.Comment.Orig,
},
),
destination := fmt.Sprintf(
"slack:%s?title=%s&attachmentText=%s&titleLink=%s",
s.channelName,
url.QueryEscape(title),
url.QueryEscape(req.Comment.Orig),
url.QueryEscape(req.Comment.Locator.URL+uiNav+req.Comment.ID),
)
return err
return s.Slack.Send(ctx, destination, "New comment from "+user)
}
// SendVerification is not implemented for Slack
func (t *Slack) SendVerification(_ context.Context, _ VerificationRequest) error {
func (s *Slack) SendVerification(_ context.Context, _ VerificationRequest) error {
return nil
}
func (t *Slack) String() string {
return "slack: " + t.channelName + " (" + t.channelID + ")"
}
func (t *Slack) findChannelIDByName(name string) (string, error) {
params := slack.GetConversationsParameters{}
for {
chans, next, err := t.client.GetConversations(&params)
if err != nil {
return "", err
}
for _, channel := range chans {
if channel.Name == name {
return channel.ID, nil
}
}
if next == "" {
break
}
params.Cursor = next
}
return "", errors.New("no such channel")
func (s *Slack) String() string {
return s.Slack.String() + " for channel " + s.channelName + ""
}
+13 -143
View File
@@ -2,169 +2,39 @@ package notify
import (
"context"
"log"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/slack-go/slack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
)
func TestSlack_New(t *testing.T) {
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "C12345678", tb.channelID)
_, err = ts.newClient("unknown-channel")
require.Error(t, err)
assert.Contains(t, err.Error(), "no such channel")
ts := NewSlack("", "")
assert.NotNil(t, ts)
assert.Equal(t, "general", ts.channelName)
}
func TestSlack_Send(t *testing.T) {
ts := NewSlack("", "")
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
c := store.Comment{PostTitle: "test title", Text: "some text", ParentID: "1", ID: "999"}
c.User.Name = "from"
cp := store.Comment{Text: "some parent text"}
cp.User.Name = "to"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
c.PostTitle = "test title"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
c.PostTitle = "[test title]"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
tb, err = ts.newClient("general")
assert.NoError(t, err)
ts.isServerDown = true
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
require.Error(t, err)
assert.Contains(t, err.Error(), "slack server error", "send on broken client")
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := ts.Send(ctx, Request{Comment: c, parent: cp})
assert.Error(t, err)
}
func TestSlack_Name(t *testing.T) {
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "slack: general (C12345678)", tb.String())
tb := NewSlack("", "test-channel")
assert.Equal(t, "slack notifications destination for channel test-channel", tb.String())
}
func TestSlack_SendVerification(t *testing.T) {
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
err = tb.SendVerification(context.TODO(), VerificationRequest{})
assert.NoError(t, err)
}
type mockSlackServer struct {
*httptest.Server
isServerDown bool
}
func (ts *mockSlackServer) newClient(channelName string) (*Slack, error) {
return NewSlack("any-token", channelName, slack.OptionAPIURL(ts.URL+"/"))
}
func newMockSlackServer() *mockSlackServer {
mockServer := mockSlackServer{}
router := chi.NewRouter()
router.Post("/conversations.list", func(w http.ResponseWriter, r *http.Request) {
s := `{
"ok": true,
"channels": [
{
"id": "C12345678",
"name": "general",
"is_channel": true,
"is_group": false,
"is_im": false,
"created": 1503888888,
"is_archived": false,
"is_general": false,
"unlinked": 0,
"name_normalized": "random",
"is_shared": false,
"parent_conversation": null,
"creator": "U12345678",
"is_ext_shared": false,
"is_org_shared": false,
"pending_shared": [],
"pending_connected_team_ids": [],
"is_pending_ext_shared": false,
"is_member": false,
"is_private": false,
"is_mpim": false,
"previous_names": [],
"num_members": 1
}
],
"response_metadata": {
"next_cursor": ""
}
}`
_, _ = w.Write([]byte(s))
})
router.Post("/chat.postMessage", func(w http.ResponseWriter, r *http.Request) {
if mockServer.isServerDown {
w.WriteHeader(500)
} else {
s := `{
"ok": true,
"channel": "C12345678",
"ts": "1617008342.000100",
"message": {
"type": "message",
"subtype": "bot_message",
"text": "wowo",
"ts": "1617008342.000100",
"username": "slackbot",
"bot_id": "B12345678"
}
}`
_, _ = w.Write([]byte(s))
}
})
router.NotFound(func(w http.ResponseWriter, r *http.Request) {
log.Printf("..... 404 for %s .....\n", r.URL)
})
mockServer.Server = httptest.NewServer(router)
return &mockServer
ts := NewSlack("", "")
assert.NoError(t, ts.SendVerification(context.Background(), VerificationRequest{}))
}
+80 -125
View File
@@ -1,162 +1,117 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
"strconv"
"strings"
"time"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater"
"github.com/pkg/errors"
ntf "github.com/go-pkgz/notify"
)
const commentTextLengthLimit = 100
// TelegramParams contain settings for telegram notifications
type TelegramParams struct {
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
Token string // token for telegram bot API interactions
Timeout time.Duration // http client timeout
UserNotifications bool // flag which enables user notifications
ErrorMsg, SuccessMsg string // messages for successful and unsuccessful subscription requests to bot
}
// Telegram implements notify.Destination for telegram
type Telegram struct {
channelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
token string
apiPrefix string
timeout time.Duration
}
*ntf.Telegram
const telegramTimeOut = 5000 * time.Millisecond
const telegramAPIPrefix = "https://api.telegram.org/bot"
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
UserNotifications bool // flag which enables user notifications
}
// NewTelegram makes telegram bot for notifications
func NewTelegram(token, channelID string, timeout time.Duration, api string) (*Telegram, error) {
if _, err := strconv.ParseInt(channelID, 10, 64); err != nil {
channelID = "@" + channelID // if channelID not a number enforce @ prefix
}
res := Telegram{channelID: channelID, token: token, apiPrefix: api, timeout: timeout}
if res.apiPrefix == "" {
res.apiPrefix = telegramAPIPrefix
}
if res.timeout == 0 {
res.timeout = telegramTimeOut
}
log.Printf("[DEBUG] create new telegram notifier for chan %s, timeout=%s, api=%s", channelID, res.timeout, res.timeout)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error {
client := http.Client{Timeout: telegramTimeOut}
resp, err := client.Get(fmt.Sprintf("%s%s/getMe", res.apiPrefix, token))
if err != nil {
return errors.Wrap(err, "can't initialize telegram notifications")
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("[WARN] can't close request body, %s", err)
}
}()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("unexpected telegram status code %d", resp.StatusCode)
}
tgResp := struct {
OK bool `json:"ok"`
Result struct {
FirstName string `json:"first_name"`
ID uint64 `json:"id"`
IsBot bool `json:"is_bot"`
UserName string `json:"username"`
}
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
return errors.Wrap(err, "can't decode response")
}
if !tgResp.OK || !tgResp.Result.IsBot {
return errors.Errorf("unexpected telegram response %+v", tgResp)
}
return nil
func NewTelegram(params TelegramParams) (*Telegram, error) {
client, err := ntf.NewTelegram(ntf.TelegramParams{
Token: params.Token,
Timeout: params.Timeout,
ErrorMsg: params.ErrorMsg,
SuccessMsg: params.SuccessMsg,
})
if err != nil {
return nil, err
}
return &res, err
return &Telegram{Telegram: client, AdminChannelID: params.AdminChannelID, UserNotifications: params.UserNotifications}, nil
}
// Send to telegram channel
// Send to telegram recipients
func (t *Telegram) Send(ctx context.Context, req Request) error {
client := http.Client{Timeout: telegramTimeOut}
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.Comment.ID)
log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID)
var errs []error
from := req.Comment.User.Name
if req.Comment.ParentID != "" {
from += " → " + req.parent.User.Name
}
from = "*" + from + "*"
link := fmt.Sprintf("↦ [original comment](%s)", req.Comment.Locator.URL+uiNav+req.Comment.ID)
if req.Comment.PostTitle != "" {
link = fmt.Sprintf("↦ [%s](%s)", t.escapeTitle(req.Comment.PostTitle), req.Comment.Locator.URL+uiNav+req.Comment.ID)
}
u := fmt.Sprintf("%s%s/sendMessage?chat_id=%s&parse_mode=Markdown&disable_web_page_preview=true",
t.apiPrefix, t.token, t.channelID)
msg := t.buildMessage(req)
msg := fmt.Sprintf("%s\n\n%s\n\n%s", from, req.Comment.Orig, link)
msg = html.UnescapeString(msg)
body := struct {
Text string `json:"text"`
}{Text: msg}
b, err := json.Marshal(body)
if err != nil {
return errors.Wrap(err, "failed to make telegram body")
}
r, err := http.NewRequest("POST", u, bytes.NewReader(b))
if err != nil {
return errors.Wrap(err, "failed to make telegram request")
}
r.Header.Set("Content-Type", "application/json; charset=utf-8")
r = r.WithContext(ctx)
resp, err := client.Do(r)
if err != nil {
return errors.Wrap(err, "failed to get telegram response")
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("[WARN] can't close request body, %s", err)
if t.AdminChannelID != "" {
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", t.AdminChannelID), msg)
if err != nil {
errs = append(errs,
fmt.Errorf("problem sending admin telegram notification about comment ID %s to %s: %w",
req.Comment.ID, t.AdminChannelID, err,
),
)
}
}()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("unexpected telegram status code %d for url %q", resp.StatusCode, u)
}
tgResp := struct {
OK bool `json:"ok"`
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
return errors.Wrap(err, "can't decode telegram response")
if t.UserNotifications {
for _, user := range req.Telegrams {
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", user), msg)
if err != nil {
errs = append(errs,
fmt.Errorf("problem sending user telegram notification about comment ID %s to %q: %w",
req.Comment.ID, user, err,
),
)
}
}
}
return nil
return errors.Join(errs...)
}
func (t *Telegram) escapeTitle(title string) string {
escSymbols := []string{"[", "]", "(", ")"}
res := title
for _, esc := range escSymbols {
res = strings.Replace(res, esc, "\\"+esc, -1)
// buildMessage generates message for generic notification about new comment
func (t *Telegram) buildMessage(req Request) string {
commentURLPrefix := req.Comment.Locator.URL + uiNav
msg := fmt.Sprintf(`<a href=%q>%s</a>`, commentURLPrefix+req.Comment.ID, ntf.EscapeTelegramText(req.Comment.User.Name))
if req.Comment.ParentID != "" {
msg += fmt.Sprintf(" -> <a href=%q>%s</a>", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name))
}
return res
msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), commentTextLengthLimit))
if req.Comment.ParentID != "" {
msg += fmt.Sprintf("\n\n\"<i>%s</i>\"", pruneHTML(ntf.TelegramSupportedHTML(req.parent.Text), commentTextLengthLimit))
}
if req.Comment.PostTitle != "" {
msg += fmt.Sprintf("\n\n↦ <a href=%q>%s</a>", req.Comment.Locator.URL, ntf.EscapeTelegramText(req.Comment.PostTitle))
}
return msg
}
// SendVerification is not implemented for telegram
// SendVerification is not needed for telegram
func (t *Telegram) SendVerification(_ context.Context, _ VerificationRequest) error {
return nil
}
func (t *Telegram) String() string {
return "telegram: " + t.channelID
result := t.Telegram.String()
if t.AdminChannelID != "" {
result += " with admin notifications to " + t.AdminChannelID
}
if t.UserNotifications {
result += " with user notifications enabled"
}
return result
}
+44 -140
View File
@@ -2,165 +2,69 @@ package notify
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/go-chi/chi/v5"
ntf "github.com/go-pkgz/notify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
)
func TestTelegram_New(t *testing.T) {
ts := mockTelegramServer()
defer ts.Close()
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "@remark_test", tb.channelID, "@ added")
st := time.Now()
_, err = NewTelegram("bad-resp", "remark_test", 2*time.Second, ts.URL+"/")
assert.EqualError(t, err, "unexpected telegram response {OK:false Result:{FirstName:comments_test ID:707381019 IsBot:false UserName:remark42_test_bot}}")
assert.True(t, time.Since(st) >= 250*5*time.Millisecond)
_, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
func TestTelegram_NewError(t *testing.T) {
tb, err := NewTelegram(TelegramParams{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "can't decode response:")
_, err = NewTelegram("404", "remark_test", 2*time.Second, ts.URL+"/")
assert.EqualError(t, err, "unexpected telegram status code 404")
_, err = NewTelegram("no-such-thing", "remark_test", 2*time.Second, "http://127.0.0.1:4321/")
require.Error(t, err)
assert.Contains(t, err.Error(), "can't initialize telegram notifications")
assert.Contains(t, err.Error(), "dial tcp 127.0.0.1:4321: connect: connection refused")
_, err = NewTelegram("good-token", "remark_test", 2*time.Second, "")
assert.Error(t, err, "empty api url not allowed")
_, err = NewTelegram("good-token", "remark_test", 0, ts.URL+"/")
assert.NoError(t, err, "0 timeout allowed as default")
tb, err = NewTelegram("good-token", "1234567890", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "1234567890", tb.channelID, "no @ prefix")
assert.Nil(t, tb)
}
func TestTelegram_Send(t *testing.T) {
ts := mockTelegramServer()
defer ts.Close()
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
tb := Telegram{
AdminChannelID: "remark_test",
UserNotifications: true,
Telegram: &ntf.Telegram{}, // broken sender due to unset API
}
assert.Equal(t, "telegram notifications destination with admin notifications to remark_test with user notifications enabled", tb.String())
c := store.Comment{Text: "some text", ParentID: "1", ID: "999", PostTitle: "[test title]", Locator: store.Locator{URL: "http://example.org/"}}
c.User.Name = "from"
cp := store.Comment{Text: "some parent text"}
cp := store.Comment{Text: `<p>some parent text with a <a href="http://example.org">link</a> and special text:<br>& < > &</p>`}
cp.User.Name = "to"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
c.PostTitle = "test title"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
err := tb.Send(context.Background(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
assert.Error(t, err)
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")
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
c.PostTitle = "[test title]"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
// test buildMessage separately for message text
res := tb.buildMessage(Request{Comment: c, parent: cp})
assert.Equal(t, `<a href="http://example.org/#remark42__comment-999">from</a> -> <a href="http://example.org/#remark42__comment-">to</a>
tb, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
assert.Error(t, err, "should failed")
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected telegram status code 404", "send on broken tg")
some text
assert.Equal(t, "telegram: @remark_test", tb.String())
"<i>some parent text with a <a href="http://example.org">link</a> and special text:&amp; &lt; &gt; &amp;</i>"
<a href="http://example.org/">[test title]</a>`,
res)
// special case for text with h1-h6 header
ch := store.Comment{Text: "<h1>Hello</h1><h6>World</h6>", ID: "555", Locator: store.Locator{URL: "http://example.org/"}}
ch.User.Name = "from"
res = tb.buildMessage(Request{Comment: ch})
assert.Equal(t, `<a href="http://example.org/#remark42__comment-555">from</a>
<b>Hello</b><i><b>World</b></i>`,
res)
// prune string keeping HTML closing tags
c = store.Comment{
Text: "<b>Lorem ipsum <i>dolor sit amet</i>, consectetur adipiscing <code>elit, sed do eiusmod tempor incididunt</code> ut labore et dolore magna aliqua.</b>",
}
res = tb.buildMessage(Request{Comment: c})
assert.Equal(t, `<a href="#remark42__comment-"></a>
<b>Lorem ipsum <i>dolor sit amet</i>, consectetur adipiscing <code>elit, sed do eiusmod tempor incididunt</code> ut...</b>`, res)
}
func TestTelegram_SendVerification(t *testing.T) {
ts := mockTelegramServer()
defer ts.Close()
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
err = tb.SendVerification(context.TODO(), VerificationRequest{})
assert.NoError(t, err)
}
func mockTelegramServer() *httptest.Server {
router := chi.NewRouter()
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `{"ok": true,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": true,
"username": "remark42_test_bot"
}}`
_, _ = w.Write([]byte(s))
})
router.Get("/bad-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `{"ok": false,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": false,
"username": "remark42_test_bot"
}}`
_, _ = w.Write([]byte(s))
})
router.Get("/non-json-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `"ok": false,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": false,
"username": "remark42_test_bot"
`
_, _ = w.Write([]byte(s))
})
router.Get("/404/getMe", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
})
router.Post("/good-token/sendMessage", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok": true}`))
})
return httptest.NewServer(router)
}
func TestTelegram_escapeTitle(t *testing.T) {
tbl := []struct {
inp string
out string
}{
{"", ""},
{"something 123", "something 123"},
{"something [123]", "something \\[123\\]"},
{"something (123)", "something \\(123\\)"},
{"something (123) [aaa]", "something \\(123\\) \\[aaa\\]"},
}
tb := Telegram{}
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
assert.Equal(t, tt.out, tb.escapeTitle(tt.inp))
})
}
// empty VerificationRequest should return no error and do nothing, as well as any other
assert.NoError(t, tb.SendVerification(context.Background(), VerificationRequest{}))
}
+94
View File
@@ -0,0 +1,94 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"text/template"
"time"
log "github.com/go-pkgz/lgr"
ntf "github.com/go-pkgz/notify"
)
const (
webhookDefaultTemplate = `{"text": {{.Text | escapeJSONString}}}`
)
// WebhookParams contain settings for webhook notifications
type WebhookParams struct {
URL string
Template string
Headers []string
Timeout time.Duration
}
// Webhook implements notify.Destination for Webhook notifications
type Webhook struct {
*ntf.Webhook
url string
template *template.Template
}
// NewWebhook makes Webhook
func NewWebhook(params WebhookParams) (*Webhook, error) {
res := &Webhook{
Webhook: ntf.NewWebhook(ntf.WebhookParams{
Timeout: params.Timeout,
Headers: params.Headers,
}),
url: params.URL,
}
if res.url == "" {
return nil, fmt.Errorf("webhook URL is required for webhook notifications")
}
if params.Template == "" {
params.Template = webhookDefaultTemplate
}
payloadTmpl, err := template.New("webhook").Funcs(template.FuncMap{"escapeJSONString": escapeJSONString}).Parse(params.Template)
if err != nil {
return nil, fmt.Errorf("unable to parse webhook template: %w", err)
}
res.template = payloadTmpl
log.Printf("[DEBUG] create new webhook notifier for %s", res.url)
return res, nil
}
// Send sends Webhook notification
func (w *Webhook) Send(ctx context.Context, req Request) error {
log.Printf("[DEBUG] send webhook notification, comment id %s", req.Comment.ID)
var payload bytes.Buffer
err := w.template.Execute(&payload, req.Comment)
if err != nil {
return fmt.Errorf("unable to compile webhook template: %w", err)
}
return w.Webhook.Send(ctx, w.url, payload.String())
}
// SendVerification is not implemented for Webhook
func (w *Webhook) SendVerification(_ context.Context, _ VerificationRequest) error {
return nil
}
// String describes the webhook instance
func (w *Webhook) String() string {
return fmt.Sprintf("%s to %s", w.Webhook.String(), w.url)
}
// escapeJSONString escapes string for JSON
func escapeJSONString(s string) (string, error) {
b, err := json.Marshal(s)
if err != nil {
return "", err
}
return string(b), nil
}
+118
View File
@@ -0,0 +1,118 @@
package notify
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
)
func TestWebhook_NewWebhook(t *testing.T) {
wh, err := NewWebhook(WebhookParams{
URL: "https://example.org/webhook",
Headers: []string{"Authorization:Basic AXVubzpwQDU1dzByYM=="},
})
assert.NoError(t, err)
assert.NotNil(t, wh)
assert.Equal(t, "https://example.org/webhook", wh.url)
assert.Equal(t, []string{"Authorization:Basic AXVubzpwQDU1dzByYM=="}, wh.Headers)
assert.NotNil(t, wh.template)
wh, err = NewWebhook(WebhookParams{})
assert.Nil(t, wh)
assert.Error(t, err)
assert.Equal(t, "webhook URL is required for webhook notifications", err.Error())
wh, err = NewWebhook(WebhookParams{URL: "https://example.org/webhook", Template: "{{.Text"})
assert.Nil(t, wh)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unable to parse webhook template")
}
// https://github.com/umputun/remark42/issues/1791
func TestWebhook_ReceiveValidJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/webhook-notify")
assert.Equal(t, "POST", r.Method)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
t.Log("received body", string(body))
assert.JSONEq(t, `{"text": "<p>testme</p>\n"}`, string(body))
}))
defer ts.Close()
wh, err := NewWebhook(WebhookParams{
URL: ts.URL + "/webhook-notify",
Headers: []string{"Content-Type:application/json,text/plain"},
})
assert.NoError(t, err)
assert.NotNil(t, wh)
f := store.NewCommentFormatter()
c := store.Comment{Text: f.FormatText("testme", false), ParentID: "1", ID: "999"}
err = wh.Send(context.Background(), Request{Comment: c})
assert.NoError(t, err)
}
func TestWebhook_Send(t *testing.T) {
wh, err := NewWebhook(WebhookParams{
URL: "bad-url",
Headers: []string{"Content-Type:application/json,text/plain", ""},
})
assert.NoError(t, err)
assert.NotNil(t, wh)
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
c.User.Name = "from"
err = wh.Send(context.Background(), Request{Comment: c})
assert.Error(t, err)
wh, err = NewWebhook(WebhookParams{
URL: "https://example.org/webhook",
Template: "{{.InvalidProperty}}",
})
assert.NoError(t, err)
err = wh.Send(context.Background(), Request{Comment: c})
require.Error(t, err)
assert.Contains(t, err.Error(), "webhook template")
wh, err = NewWebhook(WebhookParams{URL: "https://example.org/webhook"})
assert.NoError(t, err)
err = wh.Send(nil, Request{Comment: c}) // nolint
require.Error(t, err)
assert.Contains(t, err.Error(), "unable to create webhook request")
wh, err = NewWebhook(WebhookParams{URL: "https://not-existing-url.net"})
assert.NoError(t, err)
err = wh.Send(context.Background(), Request{Comment: c})
require.Error(t, err)
assert.Contains(t, err.Error(), "webhook request failed")
}
func TestWebhook_SendVerification(t *testing.T) {
wh, err := NewWebhook(WebhookParams{URL: "https://example.org/webhook"})
assert.NoError(t, err)
assert.NotNil(t, wh)
err = wh.SendVerification(context.Background(), VerificationRequest{})
assert.NoError(t, err)
}
func TestWebhook_String(t *testing.T) {
wh, err := NewWebhook(WebhookParams{URL: "https://example.org/webhook", Timeout: time.Minute * 5})
assert.NoError(t, err)
assert.NotNil(t, wh)
str := wh.String()
assert.Equal(t, "webhook notification with timeout 5m0s to https://example.org/webhook", str)
}
+71
View File
@@ -0,0 +1,71 @@
package providers
// Both Telegram auth and notifications need to receive messages received by Telegram bot in the loop,
// and below is the implementation of such loop which dispatched received events to both receivers,
// so that they could work at the same time.
import (
"context"
"encoding/json"
"fmt"
"time"
log "github.com/go-pkgz/lgr"
ntf "github.com/go-pkgz/notify"
)
type tgRequester interface {
Request(ctx context.Context, method string, b []byte, data any) error
}
// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers
type TGUpdatesReceiver interface {
fmt.Stringer
ProcessUpdate(ctx context.Context, textUpdate string) error
}
// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers
// Blocks caller
func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) {
// identifier of the first update to be requested.
// should be equal to LastSeenUpdateID + 1
// See https://core.telegram.org/bots/api#getupdates
var updateOffset int
processUpdatedTicker := time.NewTicker(period)
for {
select {
case <-ctx.Done():
processUpdatedTicker.Stop()
return
case <-processUpdatedTicker.C:
url := `getUpdates?allowed_updates=["message"]`
if updateOffset != 0 {
url += fmt.Sprintf("&offset=%d", updateOffset)
}
var update ntf.TelegramUpdate
err := requester.Request(ctx, url, nil, &update)
if err != nil {
log.Printf("[WARN] failed to fetch updates: %v", err)
continue
}
for _, u := range update.Result {
if u.UpdateID >= updateOffset {
updateOffset = u.UpdateID + 1
}
}
if raw, err := json.Marshal(update); err == nil {
for _, r := range receivers {
e := r.ProcessUpdate(ctx, string(raw))
if e != nil {
log.Printf("[ERROR] failure from destination %s on processing telegram update %v", r, e)
}
}
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package providers
import (
"context"
"encoding/json"
"fmt"
"testing"
"testing/synctest"
"time"
ntf "github.com/go-pkgz/notify"
"github.com/stretchr/testify/assert"
)
func TestDispatchTelegramUpdates(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
poolPeriod := time.Millisecond * 100
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
time.Sleep(poolPeriod * 3)
cancel()
synctest.Wait()
})
}
const getUpdatesResp = `{
"ok": true,
"result": [
{
"update_id": 998,
"message": {
"chat": {
"type": "group"
}
}
}
]
}`
type mockTGRequester struct {
hit int
t *testing.T
}
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data any) error {
if m.hit < 2 {
m.hit++
assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data))
return nil
}
return fmt.Errorf("test error")
}
type mockTGUpdatesReceiver struct {
t *testing.T
hit int
}
func (m *mockTGUpdatesReceiver) String() string {
return "mock updater"
}
func (m *mockTGUpdatesReceiver) ProcessUpdate(_ context.Context, textUpdate string) error {
var result ntf.TelegramUpdate
err := json.Unmarshal([]byte(textUpdate), &result)
assert.NoError(m.t, err)
if m.hit < 2 {
assert.NotNil(m.t, result.Result)
m.hit++
return nil
}
assert.Nil(m.t, result.Result)
return fmt.Errorf("test error")
}
+62 -46
View File
@@ -2,14 +2,15 @@ package api
import (
"errors"
"fmt"
"net/http"
"path"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/go-pkgz/auth"
cache "github.com/go-pkgz/lcw"
"github.com/go-pkgz/auth/v2"
"github.com/go-pkgz/auth/v2/avatar"
cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
@@ -29,23 +30,22 @@ type admin struct {
type adminStore interface {
Delete(locator store.Locator, commentID string, mode store.DeleteMode) error
DeleteUser(siteID string, userID string, mode store.DeleteMode) error
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
DeleteUser(siteID, userID string, mode store.DeleteMode) error
DeleteUserDetail(siteID, userID string, detail engine.UserDetail) error
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
IsBlocked(siteID string, userID string) bool
SetBlock(siteID string, userID string, status bool, ttl time.Duration) error
IsBlocked(siteID, userID string) bool
SetBlock(siteID, userID string, status bool, ttl time.Duration) error
BlockedUsers(siteID string) ([]store.BlockedUser, error)
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
SetTitle(locator store.Locator, commentID string) (comment store.Comment, err error)
SetVerified(siteID string, userID string, status bool) error
SetVerified(siteID, userID string, status bool) error
SetReadOnly(locator store.Locator, status bool) error
SetPin(locator store.Locator, commentID string, status bool) error
}
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
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")}
log.Printf("[INFO] delete comment %s", id)
@@ -55,14 +55,12 @@ func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
return
}
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope))
render.Status(r, http.StatusOK)
render.JSON(w, r, R.JSON{"id": id, "locator": locator})
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
}
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
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")
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
@@ -71,14 +69,12 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
return
}
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
render.Status(r, http.StatusOK)
render.JSON(w, r, R.JSON{"user_id": userID, "site_id": siteID})
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID})
}
// GET /user/{userid}?site=side-id - get user info for requested userid
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")
log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
@@ -87,14 +83,12 @@ func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get user info", rest.ErrInternal)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, ucomments[0].User)
R.RenderJSON(w, ucomments[0].User)
}
// GET /deleteme?token=jwt - delete all user comments and details by user's request. Gets info about deleted used from provided token
// request made GET to allow direct click from the email sent by user
func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
claims, err := a.authenticator.TokenService().Parse(token)
@@ -107,37 +101,60 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
// deleteme set by deleteMeCtrl, this check just to make sure we not trying to delete with leaked token
if !claims.User.BoolAttr("delete_me") {
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("forbidden"), "can't use provided token", rest.ErrNoAccess)
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("forbidden"), "can't use provided token", rest.ErrNoAccess)
return
}
if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.UserEmail); err != nil {
// audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
if len(claims.Audience) != 1 {
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("bad request"), "can't process token, claims.Audience expected to be a single element but it's not", rest.ErrActionRejected)
return
}
audience := claims.Audience[0]
if err = a.dataService.DeleteUserDetail(audience, claims.User.ID, engine.AllUserDetails); err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code)
return
}
if err = a.dataService.DeleteUser(claims.Audience, claims.User.ID, store.HardDelete); err != nil {
if err = a.dataService.DeleteUser(audience, claims.User.ID, store.HardDelete); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user", rest.ErrNoAccess)
return
}
if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil {
avatarStore := a.authenticator.AvatarProxy().Store
if err = avatarStore.Remove(path.Base(claims.User.Picture)); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar", rest.ErrInternal)
return
if avatarID := avatarIDFromPicture(claims.User.Picture); avatarID != "" {
// an already-removed avatar is fine (a repeated request stays idempotent), but a genuine
// store failure is surfaced now that avatar.ErrNotFound lets us tell the two apart
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)
}
}
a.cache.Flush(cache.Flusher(claims.Audience).Scopes(claims.Audience, claims.User.ID, lastCommentsScope))
render.Status(r, http.StatusOK)
render.JSON(w, r, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
a.cache.Flush(cache.Flusher(audience).Scopes(audience, claims.User.ID, lastCommentsScope))
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
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")
blockStatus := r.URL.Query().Get("block") == "1"
@@ -160,7 +177,7 @@ func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
}
}
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
render.JSON(w, r, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
}
// GET /blocked?site=siteID - list blocked users
@@ -171,7 +188,7 @@ func (a *admin) blockedUsersCtrl(w http.ResponseWriter, r *http.Request) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get blocked users", rest.ErrSiteNotFound)
return
}
render.JSON(w, r, users)
R.RenderJSON(w, users)
}
// PUT /readonly?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
@@ -187,7 +204,7 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
// don't allow to reset ro for posts turned to ro by ReadOnlyAge
if !roStatus {
if info, e := a.dataService.Info(locator, a.readOnlyAge); e == nil && isRoByAge(info) {
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"),
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"),
"read-only due the age", rest.ErrActionRejected)
return
}
@@ -198,12 +215,12 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
return
}
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, locator.SiteID))
render.JSON(w, r, R.JSON{"locator": locator, "read-only": roStatus})
R.RenderJSON(w, R.JSON{"locator": locator, "read-only": roStatus})
}
// 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) {
id := chi.URLParam(r, "id")
id := r.PathValue("id")
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
c, err := a.dataService.SetTitle(locator, id)
@@ -214,13 +231,12 @@ func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
log.Printf("[INFO] set comment's title %s to %q", id, c.PostTitle)
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, lastCommentsScope))
render.Status(r, http.StatusOK)
render.JSON(w, r, 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) {
userID := chi.URLParam(r, "userid")
userID := r.PathValue("userid")
siteID := r.URL.Query().Get("site")
verifyStatus := r.URL.Query().Get("verified") == "1"
@@ -229,13 +245,13 @@ func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
return
}
a.cache.Flush(cache.Flusher(siteID).Scopes(siteID, userID))
render.JSON(w, r, R.JSON{"user": userID, "verified": verifyStatus})
R.RenderJSON(w, R.JSON{"user": userID, "verified": verifyStatus})
}
// PUT /pin/{id}?site=siteID&url=post-url&pin=1
// mark/unmark comment as a special
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")}
pinStatus := r.URL.Query().Get("pin") == "1"
@@ -244,5 +260,5 @@ func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
return
}
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL))
render.JSON(w, r, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
R.RenderJSON(w, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
}
+327 -144
View File
@@ -5,7 +5,7 @@ import (
"compress/gzip"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -13,10 +13,10 @@ import (
"testing"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw"
"github.com/go-pkgz/auth/v2/token"
cache "github.com/go-pkgz/lcw/v2"
R "github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -38,7 +38,7 @@ func TestAdmin_Delete(t *testing.T) {
// check last comments
res, code := get(t, ts.URL+"/api/v1/last/2?site=remark42")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
comments := []store.Comment{}
err := json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
@@ -48,7 +48,7 @@ func TestAdmin_Delete(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bb, err := ioutil.ReadAll(resp.Body)
bb, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
j := []store.PostInfo{}
@@ -59,35 +59,43 @@ func TestAdmin_Delete(t *testing.T) {
// delete a comment
req, err := http.NewRequest(http.MethodDelete,
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), nil)
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)
requireAdminOnly(t, req)
resp, err = sendReq(t, req, adminUmputunToken)
resp, err = sendReq(req, adminUmputunToken)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
cr := store.Comment{}
err = json.Unmarshal([]byte(body), &cr)
assert.NoError(t, err)
assert.Equal(t, "", cr.Text)
assert.True(t, cr.Deleted)
time.Sleep(250 * time.Millisecond)
// check last comments updated
res, code = get(t, ts.URL+"/api/v1/last/2?site=remark42")
assert.Equal(t, 200, code)
comments = []store.Comment{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
assert.Equal(t, 1, len(comments), "should have 1 comments")
// the last-comments list refreshes asynchronously after the delete. the polling closure runs
// off the test goroutine, so it asserts on the CollectT it is handed rather than on t, which
// also puts the real transport or decode error in the failure message
pollClient := http.Client{Timeout: waitTimeout}
defer pollClient.CloseIdleConnections()
require.EventuallyWithT(t, func(c *assert.CollectT) {
lastResp, gErr := pollClient.Get(ts.URL + "/api/v1/last/2?site=remark42")
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
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
assert.Equal(t, 200, code)
b := map[string]interface{}{}
assert.Equal(t, http.StatusOK, code)
b := map[string]any{}
err = json.Unmarshal([]byte(res), &b)
assert.NoError(t, err)
t.Logf("%#v", b)
@@ -97,7 +105,7 @@ func TestAdmin_Delete(t *testing.T) {
resp, err = post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bb, err = ioutil.ReadAll(resp.Body)
bb, err = io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
j = []store.PostInfo{}
@@ -111,7 +119,7 @@ func TestAdmin_Title(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second})
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second}, []string{"127.0.0.1"})
tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/post1" {
_, err := w.Write([]byte("<html><title>post1 blah 123</title><body> 2222</body></html>"))
@@ -136,16 +144,16 @@ func TestAdmin_Title(t *testing.T) {
addComment(t, c2, ts)
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), nil)
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL))
require.Equal(t, 200, code)
require.Equal(t, http.StatusOK, code)
cr := store.Comment{}
err = json.Unmarshal([]byte(body), &cr)
assert.NoError(t, err)
@@ -171,17 +179,17 @@ func TestAdmin_DeleteUser(t *testing.T) {
_, err = srv.DataService.Create(c3)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), nil)
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)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
resp, err := sendReq(req, adminUmputunToken)
assert.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// all 3 comments here, but for id2 they deleted
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
cmntWithInfo := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &cmntWithInfo)
assert.NoError(t, err)
@@ -219,8 +227,9 @@ func TestAdmin_Pin(t *testing.T) {
pin := func(val int) int {
client := http.Client{}
defer client.CloseIdleConnections()
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/pin/%s?site=remark42&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), nil)
fmt.Sprintf("%s/api/v1/admin/pin/%s?site=remark42&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
req.SetBasicAuth("admin", "password")
@@ -231,19 +240,19 @@ func TestAdmin_Pin(t *testing.T) {
}
code := pin(1)
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
cr := store.Comment{}
err := json.Unmarshal([]byte(body), &cr)
assert.NoError(t, err)
assert.True(t, cr.Pin)
code = pin(-1)
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
body, code = get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
cr = store.Comment{}
err = json.Unmarshal([]byte(body), &cr)
assert.NoError(t, err)
@@ -271,12 +280,12 @@ func TestAdmin_Block(t *testing.T) {
if ttl != "" {
url = url + "&ttl=" + ttl
}
req, err := http.NewRequest(http.MethodPut, url, nil)
req, err := http.NewRequest(http.MethodPut, url, http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode, body
@@ -286,7 +295,7 @@ func TestAdmin_Block(t *testing.T) {
// block permanently
code, body := block(1, "")
require.Equal(t, 200, code)
require.Equal(t, http.StatusOK, code)
j := R.JSON{}
err := json.Unmarshal(body, &j)
assert.NoError(t, err)
@@ -299,7 +308,7 @@ func TestAdmin_Block(t *testing.T) {
// get last to confirm one comment deleted
bodyStr, code := get(t, ts.URL+"/api/v1/last/10?site=remark42")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
pi := []store.PostInfo{}
assert.NoError(t, json.Unmarshal([]byte(bodyStr), &pi))
assert.Equal(t, 1, len(pi), "last status updated, one comment left")
@@ -308,15 +317,16 @@ func TestAdmin_Block(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah"]`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
pi = []store.PostInfo{}
err = json.Unmarshal(body, &pi)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1}}, pi)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
@@ -326,19 +336,21 @@ func TestAdmin_Block(t *testing.T) {
// unblock
code, body = block(-1, "")
require.Equal(t, 200, code)
require.Equal(t, http.StatusOK, code)
err = json.Unmarshal(body, &j)
assert.NoError(t, err)
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()
code, _ = block(1, "50ms")
require.Equal(t, 200, code)
code, _ = block(1, "500ms")
require.Equal(t, http.StatusOK, code)
require.True(t, srv.adminRest.dataService.IsBlocked("remark42", "user1"), "user1 blocked with ttl")
// get as regular user
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
@@ -346,11 +358,17 @@ func TestAdmin_Block(t *testing.T) {
assert.Equal(t, "test test #1", comments.Comments[2].Text, "comment not removed and not cleared")
assert.False(t, comments.Comments[2].Deleted, "not deleted")
srv.pubRest.cache = cache.NewScache(cache.NewNopCache()) // 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.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")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
@@ -379,27 +397,27 @@ func TestAdmin_BlockedList(t *testing.T) {
// block user1
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), nil)
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody)
assert.NoError(t, err)
res, err := sendReq(t, req, adminUmputunToken)
res, err := sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, 200, 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,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), nil)
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=1h", ts.URL, "user2", 1), http.NoBody)
assert.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken)
res, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, 200, res.StatusCode)
assert.Equal(t, http.StatusOK, res.StatusCode)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
require.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken)
res, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
require.Equal(t, http.StatusOK, res.StatusCode)
users := []store.BlockedUser{}
err = json.NewDecoder(res.Body).Decode(&users)
assert.NoError(t, err)
@@ -410,18 +428,33 @@ func TestAdmin_BlockedList(t *testing.T) {
assert.Equal(t, "user2", users[1].ID)
assert.Equal(t, "user2 name", users[1].Name)
t.Logf("%+v", users)
time.Sleep(150 * time.Millisecond)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
// 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)
res, err = sendReq(t, req, adminUmputunToken)
res, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
users = []store.BlockedUser{}
err = json.NewDecoder(res.Body).Decode(&users)
assert.NoError(t, err)
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) {
@@ -444,16 +477,16 @@ func TestAdmin_ReadOnly(t *testing.T) {
// set post to read-only
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), nil)
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)
resp, err := sendReq(t, req, "") // non-admin user
resp, err := sendReq(req, "") // non-admin user
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 401, resp.StatusCode)
resp, err = sendReq(t, req, adminUmputunToken)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.True(t, info.ReadOnly)
@@ -463,21 +496,21 @@ func TestAdmin_ReadOnly(t *testing.T) {
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
b, err := json.Marshal(c)
assert.NoError(t, err, "can't marshal comment %+v", c)
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", bytes.NewBuffer(b))
require.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// reset post's read-only
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), nil)
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)
resp, err = sendReq(t, req, adminUmputunToken)
resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.False(t, info.ReadOnly)
@@ -487,9 +520,9 @@ func TestAdmin_ReadOnly(t *testing.T) {
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
b, err = json.Marshal(c)
assert.NoError(t, err, "can't marshal comment %+v", c)
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site="+c.Locator.SiteID, bytes.NewBuffer(b))
require.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusCreated, resp.StatusCode)
@@ -501,24 +534,35 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
// set post to read-only
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), nil)
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)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.Error(t, err)
// test format "tree"
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&format=tree")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
assert.True(t, comments.Info.ReadOnly)
t.Logf("%+v", comments)
// test format "plain"
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah")
assert.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
assert.True(t, comments.Info.ReadOnly)
t.Logf("%+v", comments)
}
func TestAdmin_ReadOnlyWithAge(t *testing.T) {
@@ -537,29 +581,28 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
// set post to read-only
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), nil)
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)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.True(t, info.ReadOnly)
// reset post's read-only
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), nil)
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)
resp, err = sendReq(t, req, adminUmputunToken)
resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 403, resp.StatusCode)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.True(t, info.ReadOnly)
}
func TestAdmin_Verify(t *testing.T) {
ts, srv, teardown := startupT(t)
@@ -579,18 +622,18 @@ func TestAdmin_Verify(t *testing.T) {
assert.False(t, verified)
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
verified = srv.DataService.IsVerified("remark42", "user1")
assert.True(t, verified)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
@@ -599,17 +642,17 @@ func TestAdmin_Verify(t *testing.T) {
assert.True(t, comments.Comments[0].User.Verified)
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody)
assert.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
resp, err = sendReq(req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
verified = srv.DataService.IsVerified("remark42", "user1")
assert.False(t, verified)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
@@ -631,7 +674,7 @@ func TestAdmin_ExportStream(t *testing.T) {
addComment(t, c2, ts)
body, code := getWithAdminAuth(t, ts.URL+"/api/v1/admin/export?site=remark42&mode=stream")
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, 3, strings.Count(body, "\n"))
assert.Equal(t, 2, strings.Count(body, "\"text\""))
t.Logf("%s", body)
@@ -649,18 +692,19 @@ func TestAdmin_ExportFile(t *testing.T) {
addComment(t, c1, ts)
addComment(t, c2, ts)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", nil)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody)
require.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
resp, err := sendReq(req, adminUmputunToken)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/gzip", resp.Header.Get("Content-Type"))
ungzReader, err := gzip.NewReader(resp.Body)
assert.NoError(t, err)
ungzBody, err := ioutil.ReadAll(ungzReader)
assert.NoError(t, resp.Body.Close())
ungzBody, err := io.ReadAll(ungzReader)
assert.NoError(t, err)
assert.Equal(t, 3, strings.Count(string(ungzBody), "\n"))
assert.Equal(t, 2, strings.Count(string(ungzBody), "\"text\""))
@@ -695,37 +739,38 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
claims := token.Claims{
SessionOnly: true,
StandardClaims: jwt.StandardClaims{
Audience: "remark42",
Id: "1234567",
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"remark42"},
ID: "1234567",
Issuer: "remark42",
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
},
User: &token.User{
ID: "user1",
Picture: "pic.image",
Attributes: map[string]interface{}{
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{
"delete_me": true,
},
},
}
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42", 0700))
require.NoError(t, ioutil.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image", []byte("some image data"), 0600))
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42", 0o700))
require.NoError(t, os.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image", []byte("some image data"), 0o600))
tkn, err := srv.Authenticator.TokenService().Token(claims)
assert.NoError(t, err)
client := http.Client{}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
defer client.CloseIdleConnections()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.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, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_, err = srv.DataService.User("remark42", "user1", 0, 0, store.User{})
assert.EqualError(t, err, "no comments for user user1 in store")
@@ -734,6 +779,123 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
assert.NoError(t, err)
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) {
@@ -741,9 +903,9 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.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: "user1 name", ID: "user1"}}
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "provider1_user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "provider1_user2"}}
_, err := srv.DataService.Create(c1)
assert.NoError(t, err)
@@ -752,27 +914,28 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
// try with bad token
client := http.Client{}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), nil)
defer client.CloseIdleConnections()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
// try with bad auth
claims := token.Claims{
SessionOnly: true,
StandardClaims: jwt.StandardClaims{
Audience: "remark42",
Id: "1234567",
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"remark42"},
ID: "provider1_1234567",
Issuer: "remark42",
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
},
User: &token.User{
ID: "user1",
Attributes: map[string]interface{}{
ID: "provider1_user1",
Attributes: map[string]any{
"delete_me": true,
},
},
@@ -780,42 +943,62 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
tkn, err := srv.Authenticator.TokenService().Token(claims)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "bad-password")
resp, err = client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 403, resp.StatusCode)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try bad user
badClaims := claims
badClaims.User.ID = "no-such-id"
tkn, err = srv.Authenticator.TokenService().Token(badClaims)
// 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.User.ID = "no-such-id"
tkn, err = srv.Authenticator.TokenService().Token(badClaimsUser)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode, resp.Status)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.Status)
badClaimsUser.User.ID = "provider1_user1"
// try without deleteme flag
badClaims2 := claims
badClaims2.User.SetBoolAttr("delete_me", false)
tkn, err = srv.Authenticator.TokenService().Token(badClaims2)
badClaimsWithoutDeleteMe := claims
badClaimsWithoutDeleteMe.User.SetBoolAttr("delete_me", false)
tkn, err = srv.Authenticator.TokenService().Token(badClaimsWithoutDeleteMe)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
assert.NoError(t, err)
assert.Equal(t, 403, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.True(t, strings.Contains(string(b), "can't use provided token"))
assert.Contains(t, string(b), "can't use provided token")
badClaimsWithoutDeleteMe.User.SetBoolAttr("delete_me", true)
// try with wrong audience
badClaimsMultipleAudience := claims
badClaimsMultipleAudience.Audience = jwt.ClaimStrings{"remark42", "something else"}
tkn, err = srv.Authenticator.TokenService().Token(badClaimsMultipleAudience)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Contains(t, string(b), "can't process token, claims.Audience expected to be a single element but it's not")
badClaimsMultipleAudience.Audience = jwt.ClaimStrings{"remark42"}
}
func TestAdmin_GetUserInfo(t *testing.T) {
@@ -834,7 +1017,7 @@ func TestAdmin_GetUserInfo(t *testing.T) {
body, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=remark42&url=https://radio-t.com/blah",
ts.URL))
assert.Equal(t, 200, code)
assert.Equal(t, http.StatusOK, code)
u := store.User{}
err = json.Unmarshal([]byte(body), &u)
assert.NoError(t, err)
@@ -842,8 +1025,8 @@ func TestAdmin_GetUserInfo(t *testing.T) {
Admin: false, Blocked: false, Verified: false}, u)
_, code = get(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=remark42&url=https://radio-t.com/blah", ts.URL))
assert.Equal(t, 401, code, "no auth")
assert.Equal(t, http.StatusUnauthorized, code, "no auth")
_, code = getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/userX?site=remark42&url=https://radio-t.com/blah", ts.URL))
assert.Equal(t, 400, code, "no info about user")
assert.Equal(t, http.StatusBadRequest, code, "no info about user")
}
+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)
}
+439
View File
@@ -0,0 +1,439 @@
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)
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())
})
}
}
// 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"))
})
}

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