Compare commits

..
153 Commits
Author SHA1 Message Date
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
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
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
1892 changed files with 149911 additions and 106712 deletions
+2
View File
@@ -21,8 +21,10 @@ compose-dev-backend.yml
compose-dev-frontend.yml compose-dev-frontend.yml
compose-private-backend.yml compose-private-backend.yml
compose-private-frontend.yml compose-private-frontend.yml
compose-e2e-test.yml
compose-private.yml compose-private.yml
rest-client.env.json rest-client.env.json
Makefile
# generated files # generated files
*.cov *.cov
+2
View File
@@ -1,3 +1,5 @@
root = true
[*] [*]
indent_style = tab indent_style = tab
insert_final_newline = true insert_final_newline = true
+23 -15
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: debug if needed - name: debug if needed
run: if [[ "$DEBUG" == "true" ]]; then env; fi run: if [[ "$DEBUG" == "true" ]]; then env; fi
@@ -30,36 +30,44 @@ jobs:
DEBUG: ${{secrets.DEBUG}} DEBUG: ${{secrets.DEBUG}}
- name: install go - name: install go
uses: actions/setup-go@v3 uses: actions/setup-go@v5
with: with:
go-version: 1.17 go-version: "1.21"
- name: install golangci-lint and goveralls - name: test and build backend
run: |
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $GITHUB_WORKSPACE v1.49.0
go install github.com/mattn/goveralls@latest
- name: test and lint backend
run: | run: |
go test -race -timeout=60s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./... 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 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 ./... go build -race ./...
working-directory: backend/app working-directory: backend/app
env: env:
TZ: "America/Chicago" TZ: "America/Chicago"
- name: test and lint examples - name: test examples
run: | run: |
go version
$GITHUB_WORKSPACE/golangci-lint version
go test -race ./... go test -race ./...
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run --out-format=github-actions ./... go build -race ./...
working-directory: backend/_example/memory_store working-directory: backend/_example/memory_store
env: env:
TZ: "America/Chicago" TZ: "America/Chicago"
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.58
working-directory: backend/app
- name: golangci-lint on example directory
uses: golangci/golangci-lint-action@v3
with:
version: v1.58
args: --config ../../.golangci.yml
working-directory: backend/_example/memory_store
- name: submit coverage - name: submit coverage
run: goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov run: |
go install github.com/mattn/goveralls@latest
goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
working-directory: backend working-directory: backend
env: env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -29,14 +29,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: set up QEMU - name: set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: set up Docker Buildx - name: set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: available platforms - name: available platforms
run: echo ${{ steps.buildx.outputs.platforms }} run: echo ${{ steps.buildx.outputs.platforms }}
+12 -12
View File
@@ -20,19 +20,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16.15.1] node: [16.20.2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install node - name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
@@ -64,19 +64,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16.15.1] node: [16.20.2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install node - name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
@@ -108,19 +108,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16.15.1] node: [16.20.2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install node - name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
+20 -19
View File
@@ -20,19 +20,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16.15.1] node: [16.20.2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install node - name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
@@ -64,19 +64,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16.15.1] node: [16.20.2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install node - name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
@@ -108,19 +108,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16.15.1] node: [16.20.2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install node - name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
@@ -155,39 +155,40 @@ jobs:
CI_JOB_NUMBER: 1 CI_JOB_NUMBER: 1
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
run_install: false run_install: false
- name: Check bundle size - name: Check bundle size
uses: andresz1/size-limit-action@v1 uses: andresz1/size-limit-action@7313b26c76b3666c1dc41e2ca05370e201a9b7de
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
directory: ./frontend/apps/remark42 directory: ./frontend/apps/remark42
package_manager: pnpm
test: test:
name: Tests & Coverage name: Tests & Coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: [16.15.1] node: [16.20.2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install node - name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2.0.1 uses: pnpm/action-setup@v2.4.0
id: pnpm-install id: pnpm-install
with: with:
version: 7 version: 7
+3 -3
View File
@@ -20,14 +20,14 @@ jobs:
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: set up QEMU - name: set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: set up Docker Buildx - name: set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: available platforms - name: available platforms
run: echo ${{ steps.buildx.outputs.platforms }} run: echo ${{ steps.buildx.outputs.platforms }}
+3 -1
View File
@@ -6,12 +6,14 @@ on:
paths: paths:
- "frontend/apps/remark42/**" - "frontend/apps/remark42/**"
- "frontend/e2e/**" - "frontend/e2e/**"
- "frontend/Dockerfile.e2e"
pull_request: pull_request:
branches: [master] branches: [master]
paths: paths:
- "frontend/apps/remark42/**" - "frontend/apps/remark42/**"
- "frontend/e2e/**" - "frontend/e2e/**"
- "frontend/Dockerfile.e2e"
jobs: jobs:
tests: tests:
@@ -20,7 +22,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Build & run containers - name: Build & run containers
id: tests id: tests
+1
View File
@@ -25,3 +25,4 @@ compose-private.yml
/backend/_example/*/vendor /backend/_example/*/vendor
http-client.env.json http-client.env.json
/playwright-report/ /playwright-report/
/backend/app/cmd/var
+9 -9
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:16.15.1-alpine AS frontend-deps FROM --platform=$BUILDPLATFORM node:16.20-alpine AS frontend-deps
ARG SKIP_FRONTEND_TEST ARG SKIP_FRONTEND_TEST
ARG SKIP_FRONTEND_BUILD ARG SKIP_FRONTEND_BUILD
@@ -11,7 +11,7 @@ COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/
RUN \ RUN \
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \ if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
apk add --no-cache --update git && \ apk add --no-cache --update git && \
npm i -g pnpm; \ npm i -g pnpm@7; \
fi fi
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \ RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
@@ -45,7 +45,7 @@ RUN \
echo 'Skip frontend build'; \ echo 'Skip frontend build'; \
fi fi
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend FROM umputun/baseimage:buildgo-v1.13.0 as build-backend
ARG CI ARG CI
ARG GITHUB_REF ARG GITHUB_REF
@@ -54,15 +54,15 @@ ARG GIT_BRANCH
ARG SKIP_BACKEND_TEST ARG SKIP_BACKEND_TEST
ARG BACKEND_TEST_TIMEOUT ARG BACKEND_TEST_TIMEOUT
# 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 ADD backend /build/backend
# to embed the frontend files statically into Remark42 binary # to embed the frontend files statically into Remark42 binary
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/ COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
RUN find /build/backend/app/cmd/web/ -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \; RUN find /build/backend/app/cmd/web/ -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
WORKDIR /build/backend WORKDIR /build/backend
# install gcc in order to be able to go test package with -race
RUN apk --no-cache add gcc libc-dev
RUN echo go version: `go version` RUN echo go version: `go version`
# run tests # run tests
@@ -70,7 +70,7 @@ RUN \
cd app && \ cd app && \
if [ -z "$SKIP_BACKEND_TEST" ] ; then \ 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 ./... && \ 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 ./... ; \ golangci-lint run --config ../.golangci.yml ./... ; \
else \ else \
echo "skip backend tests and linter" \ echo "skip backend tests and linter" \
@@ -81,7 +81,7 @@ RUN \
echo "version=$version" && \ echo "version=$version" && \
go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app
FROM umputun/baseimage:app-v1.9.2 FROM umputun/baseimage:app-v1.13.0
ARG GITHUB_SHA ARG GITHUB_SHA
@@ -89,7 +89,7 @@ LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
org.opencontainers.image.description="Remark42 comment engine" \ org.opencontainers.image.description="Remark42 comment engine" \
org.opencontainers.image.documentation="https://remark42.com/docs/getting-started/" \ org.opencontainers.image.documentation="https://remark42.com/docs/getting-started/" \
org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \ org.opencontainers.image.source="https://github.com/umputun/remark42" \
org.opencontainers.image.title="Remark42" \ org.opencontainers.image.title="Remark42" \
org.opencontainers.image.url="https://remark42.com/" \ org.opencontainers.image.url="https://remark42.com/" \
org.opencontainers.image.revision="${GITHUB_SHA}" org.opencontainers.image.revision="${GITHUB_SHA}"
+3 -3
View File
@@ -1,4 +1,4 @@
FROM node:16.15.1-alpine AS frontend-deps FROM node:16.20.2-alpine AS frontend-deps
ENV CI=true ENV CI=true
@@ -7,7 +7,7 @@ WORKDIR /srv/frontend
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/ COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/
COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/package.json COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/package.json
RUN apk add --no-cache --update git && npm i -g pnpm RUN apk add --no-cache --update git && npm i -g pnpm@7
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 pnpm i RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 pnpm i
FROM frontend-deps AS build-frontend FROM frontend-deps AS build-frontend
@@ -19,7 +19,7 @@ WORKDIR /srv/frontend/apps/remark42/
COPY ./frontend/apps/remark42/ /srv/frontend/apps/remark42/ COPY ./frontend/apps/remark42/ /srv/frontend/apps/remark42/
RUN pnpm build RUN pnpm build
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend FROM umputun/baseimage:buildgo-v1.13.0 as build-backend
ARG GITHUB_TOKEN ARG GITHUB_TOKEN
ARG GITHUB_REF ARG GITHUB_REF
+7 -4
View File
@@ -39,14 +39,17 @@ race_test:
cd backend/app && go test -race -timeout=60s -count 1 ./... cd backend/app && go test -race -timeout=60s -count 1 ./...
backend: backend:
docker-compose -f compose-dev-backend.yml build docker compose -f compose-dev-backend.yml build
frontend: frontend:
docker-compose -f compose-dev-frontend.yml build docker compose -f compose-dev-frontend.yml build
rundev: rundev:
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true GITHUB_REF=$(GITHUB_REF) GITHUB_SHA=$(GITHUB_SHA) CI=true \ 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 build
docker-compose -f compose-private.yml up docker compose -f compose-private.yml up
e2e:
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
.PHONY: bin backend .PHONY: bin backend
+1 -1
View File
@@ -2,7 +2,7 @@
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, Yandex, Patreon and Telegram * Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon and Telegram
* Login via email * Login via email
* Optional anonymous access * Optional anonymous access
* Multi-level nested comments with both tree and plain presentations * Multi-level nested comments with both tree and plain presentations
+19 -22
View File
@@ -1,15 +1,10 @@
run: run:
timeout: 5m timeout: 5m
output:
format: tab
skip-dirs:
- vendor
linters-settings: linters-settings:
govet: govet:
check-shadowing: true enable:
maligned: - shadow
suggest-new: true
goconst: goconst:
min-len: 2 min-len: 2
min-occurrences: 2 min-occurrences: 2
@@ -31,29 +26,31 @@ linters-settings:
linters: linters:
enable: enable:
- bodyclose - bodyclose
- megacheck
- revive
- govet
- unconvert
- gas
- gocyclo
- dupl - dupl
- exportloopref
- gochecknoinits
- gocritic
- gocyclo
- gosec
- gosimple
- govet
- ineffassign
- misspell - misspell
- nakedret
- prealloc
- revive
- staticcheck
- stylecheck
- typecheck
- unconvert
- unparam - unparam
- unused - unused
- typecheck
- ineffassign
- stylecheck
- gochecknoinits
- exportloopref
- gocritic
- nakedret
- gosimple
- prealloc
fast: false fast: false
disable-all: true disable-all: true
issues: issues:
exclude-dirs:
- vendor
exclude-rules: exclude-rules:
- text: "at least one file in a package should have a package comment" - text: "at least one file in a package should have a package comment"
linters: linters:
+3 -3
View File
@@ -1,11 +1,11 @@
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend FROM umputun/baseimage:buildgo-v1.13.0 as build-backend
ADD backend /build/backend ADD backend /build/backend
WORKDIR /build/backend/_example/memory_store WORKDIR /build/backend/_example/memory_store
RUN go build -o /build/bin/memory_store -ldflags "-X main.revision=0.0.0 -s -w" RUN go build -o /build/bin/memory_store -ldflags "-X main.revision=0.0.0 -s -w"
FROM umputun/baseimage:app-v1.9.2 FROM umputun/baseimage:app-v1.13.0
ARG GITHUB_SHA ARG GITHUB_SHA
@@ -13,7 +13,7 @@ LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
org.opencontainers.image.description="Remark42 comment engine example JRPC memory store" \ 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.documentation="https://github.com/umputun/remark42/tree/master/backend/_example/memory_store" \
org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \ org.opencontainers.image.source="https://github.com/umputun/remark42" \
org.opencontainers.image.title="Remark42 JRPC example memory store" \ org.opencontainers.image.title="Remark42 JRPC example memory store" \
org.opencontainers.image.url="https://remark42.com/" \ org.opencontainers.image.url="https://remark42.com/" \
org.opencontainers.image.revision="${GITHUB_SHA}" org.opencontainers.image.revision="${GITHUB_SHA}"
+3 -3
View File
@@ -4,9 +4,9 @@
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run: 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 build`
1. `docker-compose -f compose-dev-memstore.yml up` 1. `docker compose -f compose-dev-memstore.yml up`
As usual, demo site will run on http://127.0.0.1:8080/web/ 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.
@@ -35,7 +35,8 @@ func NewMemAdminStore(key string) *MemAdmin {
return &MemAdmin{data: map[string]AdminRec{}, key: key} 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) { func (m *MemAdmin) Key(_ string) (key string, err error) {
return m.key, nil return m.key, nil
} }
+22 -21
View File
@@ -293,17 +293,17 @@ func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailE
defer m.mu.Unlock() defer m.mu.Unlock()
if req.Update == "" { // read detail value, no update requested 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: case engine.AllUserDetails:
// list of all details returned in case request is a read request // list of all details returned in case request is a read request
// (Update is not set) and does not have UserID or Detail set // (Update is not set) and does not have UserID or Detail set
if req.Update == "" && req.UserID == "" { // read list of all details if req.Update == "" && req.UserID == "" { // read list of all details
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
return m.listDetails(req.Locator) return m.listDetails(req.Locator), nil
} }
return nil, fmt.Errorf("unsupported request with userdetail all") return nil, fmt.Errorf("unsupported request with userdetail all")
default: default:
@@ -319,7 +319,8 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
switch { switch {
case req.UserDetail != "": // delete user detail 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 case req.Locator.URL != "" && req.CommentID != "" && req.UserDetail == "": // delete comment
return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode) return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode)
@@ -332,7 +333,8 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
return e 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 case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "" && req.UserDetail == "": // delete site
if _, ok := m.posts[req.Locator.SiteID]; !ok { if _, ok := m.posts[req.Locator.SiteID]; !ok {
@@ -437,29 +439,29 @@ func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
// getUserDetail returns UserDetailEntry with requested userDetail (omitting other details) // getUserDetail returns UserDetailEntry with requested userDetail (omitting other details)
// as an only element of the slice. // 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, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID { if meta.SiteID != req.Locator.SiteID {
return []engine.UserDetailEntry{}, nil return []engine.UserDetailEntry{}
} }
switch req.Detail { switch req.Detail {
case engine.UserEmail: 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: case engine.UserTelegram:
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}, nil 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 // setUserDetail sets requested userDetail, returning complete updated UserDetailEntry as an onlyIps
// element of the slice in case of success // 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 var entry metaUser
if meta, ok := m.metaUsers[req.UserID]; ok { if meta, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID { if meta.SiteID != req.Locator.SiteID {
return []engine.UserDetailEntry{}, nil return []engine.UserDetailEntry{}
} }
entry = meta entry = meta
} }
@@ -476,42 +478,42 @@ func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
case engine.UserEmail: case engine.UserEmail:
entry.Details.Email = req.Update entry.Details.Email = req.Update
m.metaUsers[req.UserID] = entry 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: case engine.UserTelegram:
entry.Details.Telegram = req.Update entry.Details.Telegram = req.Update
m.metaUsers[req.UserID] = entry m.metaUsers[req.UserID] = entry
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}, nil 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 // 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 var res []engine.UserDetailEntry
for _, u := range m.metaUsers { for _, u := range m.metaUsers {
if u.SiteID == loc.SiteID { if u.SiteID == loc.SiteID {
res = append(res, u.Details) res = append(res, u.Details)
} }
} }
return res, nil return res
} }
// deleteUserDetail deletes requested UserDetail or whole UserDetailEntry, // deleteUserDetail deletes requested UserDetail or whole UserDetailEntry,
// deletion of the absent entry doesn't produce error. // 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. // 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 var entry metaUser
if meta, ok := m.metaUsers[userID]; ok { if meta, ok := m.metaUsers[userID]; ok {
if meta.SiteID != locator.SiteID { if meta.SiteID != locator.SiteID {
return nil return
} }
entry = meta entry = meta
} }
if entry == (metaUser{}) || entry.Details == (engine.UserDetailEntry{}) { if entry == (metaUser{}) || entry.Details == (engine.UserDetailEntry{}) {
// absent entry means that we should not do anything // absent entry means that we should not do anything
return nil return
} }
switch userDetail { switch userDetail {
@@ -529,7 +531,6 @@ func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDet
} }
m.metaUsers[userID] = entry m.metaUsers[userID] = entry
return nil
} }
func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error) { func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error) {
@@ -547,12 +547,12 @@ func TestMemData_FlagListBlocked(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
blockedList := toBlocked(vv) blockedList := toBlocked(vv)
var blockedIds = make([]string, len(blockedList)) var blockedIDs = make([]string, len(blockedList))
for i, x := range blockedList { for i, x := range blockedList {
blockedIds[i] = x.ID blockedIDs[i] = x.ID
} }
require.Equal(t, 2, len(blockedList), b.metaUsers) require.Equal(t, 2, len(blockedList), b.metaUsers)
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIds) assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIDs)
t.Logf("%+v", blockedList) t.Logf("%+v", blockedList)
// check block expiration // check block expiration
@@ -624,6 +624,7 @@ func TestMemData_DeleteComment(t *testing.T) {
func TestMemData_Close(t *testing.T) { func TestMemData_Close(t *testing.T) {
b := prepMem(t) b := prepMem(t)
assert.NoError(t, b.Close()) 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) { func TestMemData_DeleteHard(t *testing.T) {
@@ -70,6 +70,17 @@ func (m *MemImage) Load(id string) ([]byte, error) {
return img, nil 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 // Commit moves image from staging to permanent
func (m *MemImage) Commit(id string) error { func (m *MemImage) Commit(id string) error {
m.mu.RLock() m.mu.RLock()
@@ -18,7 +18,7 @@ import (
) )
// gopher png for test, from https://golang.org/src/image/png/example_test.go // gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" + const rawGopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" + "+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" + "73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" + "/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
@@ -38,7 +38,9 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" + "1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg==" "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) { func TestMemImage_LoadAfterSave(t *testing.T) {
svc := NewMemImageStore() svc := NewMemImageStore()
@@ -57,7 +59,8 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, gopher, img) assert.Equal(t, gopher, img)
svc.ResetCleanupTimer(id) err = svc.ResetCleanupTimer(id)
assert.NoError(t, err)
err = svc.Commit(id) err = svc.Commit(id)
assert.NoError(t, err) assert.NoError(t, err)
@@ -70,6 +73,26 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
assert.Equal(t, gopher, img) 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) { func TestMemImage_CommitFail(t *testing.T) {
svc := NewMemImageStore() svc := NewMemImageStore()
err := svc.Commit("test_id") err := svc.Commit("test_id")
+23 -22
View File
@@ -1,42 +1,43 @@
module github.com/umputun/remark42/memory_store module github.com/umputun/remark42/memory_store
go 1.17 go 1.21
require ( require (
github.com/go-pkgz/jrpc v0.3.0 github.com/go-pkgz/jrpc v0.3.0
github.com/go-pkgz/lgr v0.10.4 github.com/go-pkgz/lgr v0.11.1
github.com/jessevdk/go-flags v1.5.0 github.com/jessevdk/go-flags v1.6.1
github.com/stretchr/testify v1.8.0 github.com/stretchr/testify v1.9.0
github.com/umputun/remark42/backend v1.10.1 github.com/umputun/remark42/backend v1.1000.0
) )
require ( require (
github.com/Depado/bfchroma/v2 v2.0.0 // indirect github.com/Depado/bfchroma/v2 v2.0.0 // indirect
github.com/PuerkitoBio/goquery v1.8.0 // indirect github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/ajg/form v1.5.1 // indirect github.com/ajg/form v1.5.1 // indirect
github.com/alecthomas/chroma/v2 v2.3.0 // indirect github.com/alecthomas/chroma/v2 v2.13.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/didip/tollbooth/v7 v7.0.1 // indirect github.com/didip/tollbooth/v7 v7.0.2 // indirect
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f // indirect github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/go-chi/chi/v5 v5.0.7 // indirect github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/go-chi/render v1.0.2 // indirect github.com/go-chi/render v1.0.3 // indirect
github.com/go-pkgz/expirable-cache v0.1.0 // indirect github.com/go-pkgz/expirable-cache/v3 v3.0.0 // indirect
github.com/go-pkgz/rest v1.16.0 // indirect github.com/go-pkgz/rest v1.19.0 // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.20 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.4.0 // indirect github.com/rs/xid v1.5.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.etcd.io/bbolt v1.3.6 // indirect go.etcd.io/bbolt v1.3.10 // indirect
golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect golang.org/x/sys v0.22.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
replace github.com/umputun/remark42/backend => ../../ replace github.com/umputun/remark42/backend v1.1000.0 => ../../
+80 -43
View File
@@ -1,90 +1,127 @@
github.com/Depado/bfchroma/v2 v2.0.0 h1:IRpN9BPkNwEpR6w1ectIcNWOuhDSLx+8f1pn83fzxx8= 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/Depado/bfchroma/v2 v2.0.0/go.mod h1:wFwW/Pw8Tnd0irzgO9Zxtxgzp3aPS8qBWlyadxujxmw=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/chroma/v2 v2.3.0 h1:83xfxrnjv8eK+Cf8qZDzNo3PPF9IbTWHs7z28GY6D0U= github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
github.com/alecthomas/chroma/v2 v2.3.0/go.mod h1:mZxeWZlxP2Dy+/8cBob2PYd8O2DwNAzave5AY7A2eQw= github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/didip/tollbooth/v7 v7.0.0/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4= github.com/didip/tollbooth/v7 v7.0.0/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4=
github.com/didip/tollbooth/v7 v7.0.1 h1:TkT4sBKoQoHQFPf7blQ54iHrZiTDnr8TceU+MulVAog= github.com/didip/tollbooth/v7 v7.0.2 h1:WYEfusYI6g64cN0qbZgekDrYfuYBZjUZd5+RlWi69p4=
github.com/didip/tollbooth/v7 v7.0.1/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4= github.com/didip/tollbooth/v7 v7.0.2/go.mod h1:RtRYfEmFGX70+ike5kSndSvLtQ3+F2EAmTI4Un/VXNc=
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f h1:jtKwihcLmUC9BAhoJ9adCUqdSSZcOdH2KL7mPTUm2aw= github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f h1:jtKwihcLmUC9BAhoJ9adCUqdSSZcOdH2KL7mPTUm2aw=
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f/go.mod h1:q9C80dnsuVRP2dAskjnXRNWdUJqtGgwG9wNrzt0019s= github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f/go.mod h1:q9C80dnsuVRP2dAskjnXRNWdUJqtGgwG9wNrzt0019s=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-pkgz/expirable-cache v0.1.0 h1:3bw0m8vlTK8qlwz5KXuygNBTkiKRTPrAGXU0Ej2AC1g=
github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs= github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs=
github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw=
github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec=
github.com/go-pkgz/jrpc v0.3.0 h1:Fls38KqPsHzvp0FWfivr6cGnncC+iFBodHBqvUPY+0U= github.com/go-pkgz/jrpc v0.3.0 h1:Fls38KqPsHzvp0FWfivr6cGnncC+iFBodHBqvUPY+0U=
github.com/go-pkgz/jrpc v0.3.0/go.mod h1:MFtKs75JESiSqVicsQkgN2iDFFuCd3gVT1/vKiwRi00= github.com/go-pkgz/jrpc v0.3.0/go.mod h1:MFtKs75JESiSqVicsQkgN2iDFFuCd3gVT1/vKiwRi00=
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw= github.com/go-pkgz/lgr v0.11.1 h1:hXFhZcznehI6imLhEa379oMOKFz7TQUmisAqb3oLOSM=
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0= github.com/go-pkgz/lgr v0.11.1/go.mod h1:tgDF4RXQnBfIgJqjgkv0yOeTQ3F1yewWIZkpUhHnAkU=
github.com/go-pkgz/rest v1.15.6/go.mod h1:KUWAqbDteYGS/CiXftomQsKjtEOifXsJ36Ka0skYbmk= github.com/go-pkgz/rest v1.15.6/go.mod h1:KUWAqbDteYGS/CiXftomQsKjtEOifXsJ36Ka0skYbmk=
github.com/go-pkgz/rest v1.16.0 h1:3An51XJXUq1XtICHvfAEp048ZCbUHBEga3g0WEu3zK0= github.com/go-pkgz/rest v1.19.0 h1:FNMi5QX5dDIkuC+/e0r+CWsTuOTwUiWMRSA16Ou+9+A=
github.com/go-pkgz/rest v1.16.0/go.mod h1:HHlLOt02NJc2sgffXBF6hYVMcRo4Gz3vjg43zTzN7VM= github.com/go-pkgz/rest v1.19.0/go.mod h1:Po+W6zQzpMPP6XDGLdAN2aW7UKk1IyrLSb48Lp1N3oQ=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 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/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/image v0.0.0-20220617043117-41969df76e82/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -338,6 +338,6 @@ func TestRPC_closeHndl(t *testing.T) {
api := fmt.Sprintf("http://localhost:%d/test", port) api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}} re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
err := re.Close() assert.NoError(t, re.Close())
assert.NoError(t, err) assert.NoError(t, re.Close(), "second call should not result in panic or errors")
} }
+10 -1
View File
@@ -35,7 +35,6 @@ func (s *RPC) imgResetClnTimerHndl(id uint64, params json.RawMessage) (rr jrpc.R
} }
err := s.img.ResetCleanupTimer(fileID) err := s.img.ResetCleanupTimer(fileID)
return jrpc.EncodeResponse(id, nil, err) return jrpc.EncodeResponse(id, nil, err)
} }
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) { func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
@@ -47,6 +46,16 @@ func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response)
return jrpc.EncodeResponse(id, value, err) 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) { func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string var fileID string
if err := json.Unmarshal(params, &fileID); err != nil { if err := json.Unmarshal(params, &fileID); err != nil {
@@ -158,4 +158,9 @@ func TestRPC_imgInfoHndl(t *testing.T) {
info, err = ri.Info() info, err = ri.Info()
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, info.FirstStagingImageTS.IsZero()) 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")
} }
@@ -60,6 +60,7 @@ func (s *RPC) addHandlers() {
"save_with_id": s.imgSaveWithIDHndl, "save_with_id": s.imgSaveWithIDHndl,
"reset_cleanup_timer": s.imgResetClnTimerHndl, "reset_cleanup_timer": s.imgResetClnTimerHndl,
"load": s.imgLoadHndl, "load": s.imgLoadHndl,
"delete": s.imgDeleteHndl,
"commit": s.imgCommitHndl, "commit": s.imgCommitHndl,
"cleanup": s.imgCleanupHndl, "cleanup": s.imgCleanupHndl,
"info": s.imgInfoHndl, "info": s.imgInfoHndl,
+28
View File
@@ -1,10 +1,12 @@
package cmd package cmd
import ( import (
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"strings"
"testing" "testing"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
@@ -16,6 +18,10 @@ func TestBackup_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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, r.URL.Path, "/api/v1/admin/export")
assert.Equal(t, "GET", r.Method) 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") fmt.Fprint(w, "blah\nblah2\n12345678\n")
})) }))
defer ts.Close() defer ts.Close()
@@ -34,6 +40,28 @@ func TestBackup_Execute(t *testing.T) {
assert.Equal(t, "blah\nblah2\n12345678\n", string(data)) 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) { func TestBackup_ExecuteFailedStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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, r.URL.Path, "/api/v1/admin/export")
+2 -2
View File
@@ -195,7 +195,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
require.NoError(t, json.NewEncoder(w).Encode(commentsWithInfo)) 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) require.Equal(t, "DELETE", r.Method)
t.Log("delete ", r.URL.Path) t.Log("delete ", r.URL.Path)
c.lock.Lock() c.lock.Lock()
@@ -203,7 +203,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
c.lock.Unlock() 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) require.Equal(t, "PUT", r.Method)
t.Log("title for ", r.URL.Path) t.Log("title for ", r.URL.Path)
c.lock.Lock() c.lock.Lock()
+3
View File
@@ -115,6 +115,9 @@ func responseError(resp *http.Response) error {
if e != nil { if e != nil {
body = []byte("") body = []byte("")
} }
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) return fmt.Errorf("error response %q, %s", resp.Status, body)
} }
+42
View File
@@ -1,10 +1,12 @@
package cmd package cmd
import ( import (
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -18,6 +20,10 @@ func TestImport_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method) 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:secret", string(auth))
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body)) assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
@@ -46,6 +52,42 @@ func TestImport_Execute(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
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) { func TestImport_ExecuteFailed(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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, r.URL.Path, "/api/v1/admin/import")
+35
View File
@@ -1,9 +1,12 @@
package cmd package cmd
import ( import (
"encoding/base64"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
@@ -16,6 +19,10 @@ func TestRemap_Execute(t *testing.T) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap") assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
assert.Equal(t, "POST", r.Method) assert.Equal(t, "POST", r.Method)
assert.Equal(t, "remark", r.URL.Query().Get("site")) 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:secret", string(auth))
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
assert.NoError(t, err) 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)) 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))
@@ -33,3 +40,31 @@ func TestRemap_Execute(t *testing.T) {
err = cmd.Execute(nil) err = cmd.Execute(nil)
assert.NoError(t, err) 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")
}
+209 -121
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"embed" "embed"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -15,7 +16,7 @@ import (
"time" "time"
"github.com/go-pkgz/jrpc" "github.com/go-pkgz/jrpc"
"github.com/go-pkgz/lcw/eventbus" "github.com/go-pkgz/lcw/v2/eventbus"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
ntf "github.com/go-pkgz/notify" ntf "github.com/go-pkgz/notify"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
@@ -27,7 +28,7 @@ import (
"github.com/go-pkgz/auth/provider" "github.com/go-pkgz/auth/provider"
"github.com/go-pkgz/auth/provider/sender" "github.com/go-pkgz/auth/provider/sender"
"github.com/go-pkgz/auth/token" "github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
"github.com/umputun/remark42/backend/app/migrator" "github.com/umputun/remark42/backend/app/migrator"
"github.com/umputun/remark42/backend/app/notify" "github.com/umputun/remark42/backend/app/notify"
@@ -58,34 +59,36 @@ type ServerCommand struct {
SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"` SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"`
ImageProxy ImageProxyGroup `group:"image-proxy" namespace:"image-proxy" env-namespace:"IMAGE_PROXY"` ImageProxy ImageProxyGroup `group:"image-proxy" namespace:"image-proxy" env-namespace:"IMAGE_PROXY"`
Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","`
AnonymousVote bool `long:"anon-vote" env:"ANON_VOTE" description:"enable anonymous votes (works only with VOTES_IP enabled)"` AnonymousVote bool `long:"anon-vote" env:"ANON_VOTE" description:"enable anonymous votes (works only with VOTES_IP enabled)"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"` AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"` BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"`
MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"` MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"`
LegacyImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"[deprecated, use image-proxy.http2https] enable image proxy"` LegacyImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"[deprecated, use image-proxy.http2https] enable image proxy"`
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"` MinCommentSize int `long:"min-comment" env:"MIN_COMMENT_SIZE" default:"0" description:"min comment size"`
MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"` MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"` MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"`
DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"` RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"`
LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"` DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"`
CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"` LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"`
PositiveScore bool `long:"positive-score" env:"POSITIVE_SCORE" description:"enable positive score only"` CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"`
ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments, days"` PositiveScore bool `long:"positive-score" env:"POSITIVE_SCORE" description:"enable positive score only"`
EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window"` ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments, days"`
AdminEdit bool `long:"admin-edit" env:"ADMIN_EDIT" description:"unlimited edit for admins"` EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window"`
Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"` AdminEdit bool `long:"admin-edit" env:"ADMIN_EDIT" description:"unlimited edit for admins"`
Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"` Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"`
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"` Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"`
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"` WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","` UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","` RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"` RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"` EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"` SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"`
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments" env-delim:","` ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"`
SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"` AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments via CSP 'frame-ancestors''" env-delim:","`
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"` SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"`
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"`
DisableFancyTextFormatting bool `long:"disable-fancy-text-formatting" env:"DISABLE_FANCY_TEXT_FORMATTING" description:"disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc)"`
Auth struct { Auth struct {
TTL struct { TTL struct {
@@ -96,16 +99,17 @@ type ServerCommand struct {
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of cookie"` SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of cookie"`
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"` Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"` Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"` Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"` Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"` Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"Twitter OAuth"` Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"` Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"`
Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"` Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"`
Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"` Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"`
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"` Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"`
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"`
Email struct { Email struct {
Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"` Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"`
From string `long:"from" env:"FROM" description:"from email address"` From string `long:"from" env:"FROM" description:"from email address"`
@@ -133,6 +137,14 @@ type ImageProxyGroup struct {
CacheExternal bool `long:"cache-external" env:"CACHE_EXTERNAL" description:"enable caching for external images"` CacheExternal bool `long:"cache-external" env:"CACHE_EXTERNAL" description:"enable caching for external images"`
} }
// AppleGroup defines options for Apple auth params
type AppleGroup struct {
CID string `long:"cid" env:"CID" description:"Apple client ID (App ID or Services ID)"`
TID string `long:"tid" env:"TID" description:"Apple service ID"`
KID string `long:"kid" env:"KID" description:"Private key ID"`
PrivateKeyFilePath string `long:"private-key-filepath" env:"PRIVATE_KEY_FILEPATH" description:"Private key file location" default:"/srv/var/apple.p8"`
}
// AuthGroup defines options group for auth params // AuthGroup defines options group for auth params
type AuthGroup struct { type AuthGroup struct {
CID string `long:"cid" env:"CID" description:"OAuth client ID"` CID string `long:"cid" env:"CID" description:"OAuth client ID"`
@@ -197,7 +209,7 @@ type AdminGroup struct {
Admins []string `long:"id" env:"ID" description:"admin(s) ids" env-delim:","` Admins []string `long:"id" env:"ID" description:"admin(s) ids" env-delim:","`
Email []string `long:"email" env:"EMAIL" description:"admin emails" env-delim:","` Email []string `long:"email" env:"EMAIL" description:"admin emails" env-delim:","`
} `group:"shared" namespace:"shared" env-namespace:"SHARED"` } `group:"shared" namespace:"shared" env-namespace:"SHARED"`
RPC RPCGroup `group:"rpc" namespace:"rpc" env-namespace:"RPC"` RPC AdminRPCGroup `group:"rpc" namespace:"rpc" env-namespace:"RPC"`
} }
// TelegramGroup defines token for Telegram used in notify and auth modules // TelegramGroup defines token for Telegram used in notify and auth modules
@@ -208,14 +220,15 @@ type TelegramGroup struct {
// SMTPGroup defines options for SMTP server connection, used in auth and notify modules // SMTPGroup defines options for SMTP server connection, used in auth and notify modules
type SMTPGroup struct { type SMTPGroup struct {
Host string `long:"host" env:"HOST" description:"SMTP host"` Host string `long:"host" env:"HOST" description:"SMTP host"`
Port int `long:"port" env:"PORT" description:"SMTP port"` Port int `long:"port" env:"PORT" description:"SMTP port"`
Username string `long:"username" env:"USERNAME" description:"SMTP user name"` Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
Password string `long:"password" env:"PASSWORD" description:"SMTP password"` Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
TLS bool `long:"tls" env:"TLS" description:"enable TLS"` TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
LoginAuth bool `long:"login_auth" env:"LOGIN_AUTH" description:"enable LOGIN auth instead of PLAIN"` InsecureSkipVerify bool `long:"insecure_skip_verify" env:"INSECURE_SKIP_VERIFY" description:"skip certificate verification"`
StartTLS bool `long:"starttls" env:"STARTTLS" description:"enable StartTLS"` LoginAuth bool `long:"login_auth" env:"LOGIN_AUTH" description:"enable LOGIN auth instead of PLAIN"`
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"` StartTLS bool `long:"starttls" env:"STARTTLS" description:"enable StartTLS"`
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"`
} }
// NotifyGroup defines options for notification // NotifyGroup defines options for notification
@@ -265,6 +278,12 @@ type RPCGroup struct {
AuthPassword string `long:"auth_passwd" env:"AUTH_PASSWD" description:"basic auth user password"` AuthPassword string `long:"auth_passwd" env:"AUTH_PASSWD" description:"basic auth user password"`
} }
// AdminRPCGroup defines options for remote admin store
type AdminRPCGroup struct {
RPCGroup
SecretPerSite bool `long:"secret_per_site" env:"SECRET_PER_SITE" description:"enable JWT secret retrieval per aud, which is site_id in this case"`
}
// LoadingCache defines interface for caching // LoadingCache defines interface for caching
type LoadingCache interface { type LoadingCache interface {
Get(key cache.Key, fn func() ([]byte, error)) (data []byte, err error) // load from cache if found or put to cache and return Get(key cache.Key, fn func() ([]byte, error)) (data []byte, err error) // load from cache if found or put to cache and return
@@ -294,6 +313,7 @@ func (s *ServerCommand) Execute(_ []string) error {
log.Printf("[INFO] start server on port %s:%d", s.Address, s.Port) log.Printf("[INFO] start server on port %s:%d", s.Address, s.Port)
resetEnv( resetEnv(
"SECRET", "SECRET",
"AUTH_APPLE_KID",
"AUTH_GOOGLE_CSEC", "AUTH_GOOGLE_CSEC",
"AUTH_GITHUB_CSEC", "AUTH_GITHUB_CSEC",
"AUTH_FACEBOOK_CSEC", "AUTH_FACEBOOK_CSEC",
@@ -387,6 +407,12 @@ func (s *ServerCommand) HandleDeprecatedFlags() (result []DeprecatedFlag) {
if s.Notify.Telegram.API != "https://api.telegram.org/bot" { if s.Notify.Telegram.API != "https://api.telegram.org/bot" {
result = append(result, DeprecatedFlag{Old: "notify.telegram.api", Version: "1.9"}) result = append(result, DeprecatedFlag{Old: "notify.telegram.api", Version: "1.9"})
} }
if s.Auth.Twitter.CID != "" {
result = append(result, DeprecatedFlag{Old: "auth.twitter.cid", Version: "1.14"})
}
if s.Auth.Twitter.CSEC != "" {
result = append(result, DeprecatedFlag{Old: "auth.twitter.csec", Version: "1.14"})
}
return append(result, s.findDeprecatedFlagsCollisions()...) return append(result, s.findDeprecatedFlagsCollisions()...)
} }
@@ -485,11 +511,12 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
EditDuration: s.EditDuration, EditDuration: s.EditDuration,
AdminEdits: s.AdminEdit, AdminEdits: s.AdminEdit,
AdminStore: adminStore, AdminStore: adminStore,
MinCommentSize: s.MinCommentSize,
MaxCommentSize: s.MaxCommentSize, MaxCommentSize: s.MaxCommentSize,
MaxVotes: s.MaxVotes, MaxVotes: s.MaxVotes,
PositiveScore: s.PositiveScore, PositiveScore: s.PositiveScore,
ImageService: imageService, ImageService: imageService,
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}), TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}, s.getAllowedDomains()),
RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}), RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}),
} }
dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP
@@ -510,11 +537,12 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
authenticator := s.getAuthenticator(dataService, avatarStore, adminStore, authRefreshCache) authenticator := s.getAuthenticator(dataService, avatarStore, adminStore, authRefreshCache)
telegramAuth := s.makeTelegramAuth(authenticator) // telegram auth requires TelegramAPI listener which is constructed below telegramAuth := s.makeTelegramAuth(authenticator) // telegram auth requires TelegramAPI listener which is constructed below
telegramService, telegramBotUsername := s.startTelegramAuthAndNotify(ctx, telegramAuth) telegramService := s.startTelegramAuthAndNotify(ctx, telegramAuth)
err = s.addAuthProviders(authenticator) err = s.addAuthProviders(authenticator)
if err != nil { if err != nil {
_ = dataService.Close() _ = dataService.Close()
_ = authRefreshCache.Close()
return nil, fmt.Errorf("failed to make authenticator: %w", err) return nil, fmt.Errorf("failed to make authenticator: %w", err)
} }
@@ -524,7 +552,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
Cache: loadingCache, Cache: loadingCache,
NativeImporter: &migrator.Native{DataStore: dataService}, NativeImporter: &migrator.Native{DataStore: dataService},
DisqusImporter: &migrator.Disqus{DataStore: dataService}, DisqusImporter: &migrator.Disqus{DataStore: dataService},
WordPressImporter: &migrator.WordPress{DataStore: dataService}, WordPressImporter: &migrator.WordPress{DataStore: dataService, DisableFancyTextFormatting: s.DisableFancyTextFormatting},
CommentoImporter: &migrator.Commento{DataStore: dataService}, CommentoImporter: &migrator.Commento{DataStore: dataService},
NativeExporter: &migrator.Native{DataStore: dataService}, NativeExporter: &migrator.Native{DataStore: dataService},
URLMapperMaker: migrator.NewURLMapper, URLMapperMaker: migrator.NewURLMapper,
@@ -554,37 +582,40 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
sslConfig, err := s.makeSSLConfig() sslConfig, err := s.makeSSLConfig()
if err != nil { if err != nil {
_ = dataService.Close() _ = dataService.Close()
_ = authRefreshCache.Close()
return nil, fmt.Errorf("failed to make config of ssl server params: %w", err) return nil, fmt.Errorf("failed to make config of ssl server params: %w", err)
} }
srv := &api.Rest{ srv := &api.Rest{
Version: s.Revision, Version: s.Revision,
DataService: dataService, DataService: dataService,
WebRoot: s.WebRoot, WebRoot: s.WebRoot,
WebFS: webFS, WebFS: webFS,
RemarkURL: s.RemarkURL, RemarkURL: s.RemarkURL,
ImageProxy: imgProxy, ImageProxy: imgProxy,
CommentFormatter: commentFormatter, CommentFormatter: commentFormatter,
Migrator: migr, Migrator: migr,
ReadOnlyAge: s.ReadOnlyAge, ReadOnlyAge: s.ReadOnlyAge,
SharedSecret: s.SharedSecret, SharedSecret: s.SharedSecret,
Authenticator: authenticator, Authenticator: authenticator,
Cache: loadingCache, Cache: loadingCache,
NotifyService: notifyService, NotifyService: notifyService,
TelegramService: telegramService, TelegramService: telegramService,
SSLConfig: sslConfig, SSLConfig: sslConfig,
UpdateLimiter: s.UpdateLimit, UpdateLimiter: s.UpdateLimit,
ImageService: imageService, ImageService: imageService,
EmailNotifications: contains("email", s.Notify.Users), EmailNotifications: contains("email", s.Notify.Users),
TelegramBotUsername: telegramBotUsername, TelegramNotifications: contains("telegram", s.Notify.Users) && telegramService != nil,
EmojiEnabled: s.EnableEmoji, EmojiEnabled: s.EnableEmoji,
AnonVote: s.AnonymousVote && s.RestrictVoteIP, AnonVote: s.AnonymousVote && s.RestrictVoteIP,
SimpleView: s.SimpleView, SimpleView: s.SimpleView,
ProxyCORS: s.ProxyCORS, ProxyCORS: s.ProxyCORS,
AllowedAncestors: s.AllowedHosts, AllowedAncestors: s.AllowedHosts,
SendJWTHeader: s.Auth.SendJWTHeader, SendJWTHeader: s.Auth.SendJWTHeader,
SubscribersOnly: s.SubscribersOnly, SubscribersOnly: s.SubscribersOnly,
DisableSignature: s.DisableSignature, DisableSignature: s.DisableSignature,
DisableFancyTextFormatting: s.DisableFancyTextFormatting,
ExternalImageProxy: s.ImageProxy.CacheExternal,
} }
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore
@@ -594,6 +625,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
da, errDevAuth := authenticator.DevAuth() da, errDevAuth := authenticator.DevAuth()
if errDevAuth != nil { if errDevAuth != nil {
_ = dataService.Close() _ = dataService.Close()
_ = authRefreshCache.Close()
return nil, fmt.Errorf("can't make dev oauth2 server: %w", errDevAuth) return nil, fmt.Errorf("can't make dev oauth2 server: %w", errDevAuth)
} }
devAuth = da devAuth = da
@@ -615,6 +647,47 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
}, nil }, nil
} }
// Extract domains from s.AllowedHosts and second level domain from s.RemarkURL.
// It can be and IP like http://127.0.0.1 in which case we need to use whole IP as domain
// Beware, if s.RemarkURL is in third-level domain like https://example.co.uk, co.uk will be returned.
func (s *ServerCommand) getAllowedDomains() []string {
rawDomains := s.AllowedHosts
rawDomains = append(rawDomains, s.RemarkURL)
allowedDomains := []string{}
for _, rawURL := range rawDomains {
// case of 'self' AllowedHosts, which is not a valid rawURL name
if rawURL == "self" || rawURL == "'self'" || rawURL == "\"self\"" {
continue
}
// AllowedHosts usually don't have https:// prefix, so we're adding it just to make parsing below work the same way as for RemarkURL
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
rawURL = "https://" + rawURL
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
log.Printf("[WARN] failed to parse URL %s for TitleExtract whitelist: %v", rawURL, err)
continue
}
domain := parsedURL.Hostname()
if domain == "" || // don't add empty domain as it will allow everything to be extracted
(len(strings.Split(domain, ".")) < 2 && // don't allow single-word domains like "com"
domain != "localhost") { // localhost is an exceptional single-word domain which is allowed
continue
}
// Only for RemarkURL if domain is not IP and has more than two levels, extract second level domain.
// For AllowedHosts we don't do this as they are exact list of domains which can host comments, but
// RemarkURL might be on a subdomain and we must allow parent domain to be used for TitleExtract.
if rawURL == s.RemarkURL && net.ParseIP(domain) == nil && len(strings.Split(domain, ".")) > 2 {
domain = strings.Join(strings.Split(domain, ".")[len(strings.Split(domain, "."))-2:], ".")
}
allowedDomains = append(allowedDomains, domain)
}
return allowedDomains
}
// Run all application objects // Run all application objects
func (a *serverApp) run(ctx context.Context) error { func (a *serverApp) run(ctx context.Context) error {
if a.AdminPasswd != "" { if a.AdminPasswd != "" {
@@ -804,37 +877,53 @@ func (s *ServerCommand) makeAdminStore() (admin.Store, error) {
func (s *ServerCommand) makeCache() (LoadingCache, error) { func (s *ServerCommand) makeCache() (LoadingCache, error) {
log.Printf("[INFO] make cache, type=%s", s.Cache.Type) log.Printf("[INFO] make cache, type=%s", s.Cache.Type)
o := cache.NewOpts[[]byte]()
switch s.Cache.Type { switch s.Cache.Type {
case "redis_pub_sub": case "redis_pub_sub":
redisPubSub, err := eventbus.NewRedisPubSub(s.Cache.RedisAddr, "remark42-cache") redisPubSub, err := eventbus.NewRedisPubSub(s.Cache.RedisAddr, "remark42-cache")
if err != nil { if err != nil {
return nil, fmt.Errorf("cache backend initialization, redis PubSub initialisation: %w", err) return nil, fmt.Errorf("cache backend initialization, redis PubSub initialisation: %w", err)
} }
backend, err := cache.NewLruCache(cache.MaxCacheSize(s.Cache.Max.Size), cache.MaxValSize(s.Cache.Max.Value), backend, err := cache.NewLruCache(o.MaxCacheSize(s.Cache.Max.Size), o.MaxValSize(s.Cache.Max.Value),
cache.MaxKeys(s.Cache.Max.Items), cache.EventBus(redisPubSub)) o.MaxKeys(s.Cache.Max.Items), o.EventBus(redisPubSub))
if err != nil { if err != nil {
return nil, fmt.Errorf("cache backend initialization: %w", err) return nil, fmt.Errorf("cache backend initialization: %w", err)
} }
return cache.NewScache(backend), nil return cache.NewScache[[]byte](backend), nil
case "mem": case "mem":
backend, err := cache.NewLruCache(cache.MaxCacheSize(s.Cache.Max.Size), cache.MaxValSize(s.Cache.Max.Value), backend, err := cache.NewLruCache(o.MaxCacheSize(s.Cache.Max.Size), o.MaxValSize(s.Cache.Max.Value),
cache.MaxKeys(s.Cache.Max.Items)) o.MaxKeys(s.Cache.Max.Items))
if err != nil { if err != nil {
return nil, fmt.Errorf("cache backend initialization: %w", err) return nil, fmt.Errorf("cache backend initialization: %w", err)
} }
return cache.NewScache(backend), nil return cache.NewScache[[]byte](backend), nil
case "none": case "none":
return cache.NewScache(&cache.Nop{}), nil return cache.NewScache[[]byte](&cache.Nop[[]byte]{}), nil
} }
return nil, fmt.Errorf("unsupported cache type %s", s.Cache.Type) return nil, fmt.Errorf("unsupported cache type %s", s.Cache.Type)
} }
//nolint:gocyclo // simple code but many if checks
func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error { func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
providersCount := 0 providersCount := 0
if s.Auth.Telegram { if s.Auth.Telegram {
providersCount++ providersCount++
} }
if s.Auth.Apple.CID != "" && s.Auth.Apple.TID != "" && s.Auth.Apple.KID != "" {
err := authenticator.AddAppleProvider(
provider.AppleConfig{
ClientID: s.Auth.Apple.CID,
TeamID: s.Auth.Apple.TID,
KeyID: s.Auth.Apple.KID,
},
provider.LoadApplePrivateKeyFromFile(s.Auth.Apple.PrivateKeyFilePath),
)
if err != nil {
return err
}
providersCount++
}
if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" { if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" {
authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC) authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC)
providersCount++ providersCount++
@@ -876,18 +965,19 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
if s.Auth.Email.Enable { if s.Auth.Email.Enable {
params := sender.EmailParams{ params := sender.EmailParams{
Host: s.SMTP.Host, Host: s.SMTP.Host,
Port: s.SMTP.Port, Port: s.SMTP.Port,
SMTPUserName: s.SMTP.Username, SMTPUserName: s.SMTP.Username,
SMTPPassword: s.SMTP.Password, SMTPPassword: s.SMTP.Password,
TimeOut: s.SMTP.TimeOut, TimeOut: s.SMTP.TimeOut,
StartTLS: s.SMTP.StartTLS, StartTLS: s.SMTP.StartTLS,
LoginAuth: s.SMTP.LoginAuth, LoginAuth: s.SMTP.LoginAuth,
TLS: s.SMTP.TLS, TLS: s.SMTP.TLS,
Charset: "UTF-8", InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
From: s.Auth.Email.From, Charset: "UTF-8",
Subject: s.Auth.Email.Subject, From: s.Auth.Email.From,
ContentType: s.Auth.Email.ContentType, Subject: s.Auth.Email.Subject,
ContentType: s.Auth.Email.ContentType,
} }
sndr := sender.NewEmailClient(params, log.Default()) sndr := sender.NewEmailClient(params, log.Default())
tmpl, err := templates.Read(s.Auth.Email.MsgTemplate) tmpl, err := templates.Read(s.Auth.Email.MsgTemplate)
@@ -1031,16 +1121,17 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
emailParams.AdminEmails = s.Admin.Shared.Email emailParams.AdminEmails = s.Admin.Shared.Email
} }
smtpParams := ntf.SMTPParams{ smtpParams := ntf.SMTPParams{
Host: s.SMTP.Host, Host: s.SMTP.Host,
Port: s.SMTP.Port, Port: s.SMTP.Port,
TLS: s.SMTP.TLS, TLS: s.SMTP.TLS,
StartTLS: s.SMTP.StartTLS, StartTLS: s.SMTP.StartTLS,
LoginAuth: s.SMTP.LoginAuth, InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
Username: s.SMTP.Username, LoginAuth: s.SMTP.LoginAuth,
Password: s.SMTP.Password, Username: s.SMTP.Username,
TimeOut: s.SMTP.TimeOut, Password: s.SMTP.Password,
ContentType: "text/html", TimeOut: s.SMTP.TimeOut,
Charset: "UTF-8", ContentType: "text/html",
Charset: "UTF-8",
} }
emailService, err := notify.NewEmail(emailParams, smtpParams) emailService, err := notify.NewEmail(emailParams, smtpParams)
if err != nil { if err != nil {
@@ -1141,7 +1232,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
return c return c
}), }),
AdminPasswd: s.AdminPasswd, AdminPasswd: s.AdminPasswd,
Validator: token.ValidatorFunc(func(token string, claims token.Claims) bool { // check on each auth call (in middleware) Validator: token.ValidatorFunc(func(_ string, claims token.Claims) bool { // check on each auth call (in middleware)
if claims.User == nil { if claims.User == nil {
return false return false
} }
@@ -1157,6 +1248,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
Logger: log.Default(), Logger: log.Default(),
RefreshCache: authRefreshCache, RefreshCache: authRefreshCache,
UseGravatar: true, UseGravatar: true,
AudSecrets: s.Admin.RPC.SecretPerSite,
}) })
} }
@@ -1177,20 +1269,15 @@ func (s *ServerCommand) parseSameSite(ss string) http.SameSite {
// startTelegramAuthAndNotify initializes telegram notify and auth Telegram Bot listen loop. // startTelegramAuthAndNotify initializes telegram notify and auth Telegram Bot listen loop.
// Does nothing if telegram auth and notifications are disabled. // Does nothing if telegram auth and notifications are disabled.
// Doesn't return telegram bot username if user notifications are disabled, as that is the way frontend knows they are enabled. func (s *ServerCommand) startTelegramAuthAndNotify(ctx context.Context, telegramAuth providers.TGUpdatesReceiver) (tg *notify.Telegram) {
func (s *ServerCommand) startTelegramAuthAndNotify(ctx context.Context, telegramAuth providers.TGUpdatesReceiver) (tg *notify.Telegram, telegramBotUsername string) {
if !contains("telegram", s.Notify.Users) && !contains("telegram", s.Notify.Admins) && !s.Auth.Telegram { if !contains("telegram", s.Notify.Users) && !contains("telegram", s.Notify.Admins) && !s.Auth.Telegram {
return nil, "" return nil
} }
var err error var err error
if tg, err = s.makeTelegramNotify(); err != nil { if tg, err = s.makeTelegramNotify(); err != nil {
log.Printf("[WARN] failed to make telegram notify service, %s", err) log.Printf("[WARN] failed to make telegram notify service, %s", err)
return nil, "" return nil
}
if contains("telegram", s.Notify.Users) {
telegramBotUsername = tg.GetBotUsername()
} }
telegramReceivers := []providers.TGUpdatesReceiver{tg} telegramReceivers := []providers.TGUpdatesReceiver{tg}
@@ -1200,7 +1287,7 @@ func (s *ServerCommand) startTelegramAuthAndNotify(ctx context.Context, telegram
// start bot messages receiver for both notify and auth services // start bot messages receiver for both notify and auth services
go providers.DispatchTelegramUpdates(ctx, tg, telegramReceivers, time.Second*5) go providers.DispatchTelegramUpdates(ctx, tg, telegramReceivers, time.Second*5)
return tg, telegramBotUsername return tg
} }
// splitAtCommas split s at commas, ignoring commas in strings. // splitAtCommas split s at commas, ignoring commas in strings.
@@ -1247,11 +1334,12 @@ func splitAtCommas(s string) []string {
// authRefreshCache used by authenticator to minimize repeatable token refreshes // authRefreshCache used by authenticator to minimize repeatable token refreshes
type authRefreshCache struct { type authRefreshCache struct {
cache.LoadingCache cache.LoadingCache[token.Claims]
} }
func newAuthRefreshCache() *authRefreshCache { func newAuthRefreshCache() *authRefreshCache {
expirableCache, _ := cache.NewExpirableCache(cache.TTL(5 * time.Minute)) o := cache.NewOpts[token.Claims]()
expirableCache, _ := cache.NewExpirableCache(o.TTL(5 * time.Minute))
return &authRefreshCache{LoadingCache: expirableCache} return &authRefreshCache{LoadingCache: expirableCache}
} }
@@ -1262,5 +1350,5 @@ func (c *authRefreshCache) Get(key interface{}) (interface{}, bool) {
// Set implements cache setter with key converted to string // Set implements cache setter with key converted to string
func (c *authRefreshCache) Set(key, value interface{}) { func (c *authRefreshCache) Set(key, value interface{}) {
_, _ = c.LoadingCache.Get(key.(string), func() (interface{}, error) { return value, nil }) _, _ = c.LoadingCache.Get(key.(string), func() (token.Claims, error) { return value.(token.Claims), nil })
} }
+57 -11
View File
@@ -79,7 +79,7 @@ func TestServerApp_DevMode(t *testing.T) {
waitForHTTPServerStart(port) waitForHTTPServerStart(port)
providers := app.restSrv.Authenticator.Providers() providers := app.restSrv.Authenticator.Providers()
require.Equal(t, 9+1, len(providers), "extra auth provider") require.Equal(t, 10+1, len(providers), "extra auth provider")
assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider") assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider")
// send ping // send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port)) resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
@@ -107,7 +107,7 @@ func TestServerApp_AnonMode(t *testing.T) {
waitForHTTPServerStart(port) waitForHTTPServerStart(port)
providers := app.restSrv.Authenticator.Providers() providers := app.restSrv.Authenticator.Providers()
require.Equal(t, 9+1, len(providers), "extra auth provider for anon") require.Equal(t, 10+1, len(providers), "extra auth provider for anon")
assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider") assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider")
client := http.Client{Timeout: 10 * time.Second} client := http.Client{Timeout: 10 * time.Second}
@@ -250,7 +250,7 @@ func TestServerApp_WithSSL(t *testing.T) {
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
@@ -290,7 +290,8 @@ func TestServerApp_WithRemote(t *testing.T) {
port := chooseRandomUnusedPort() port := chooseRandomUnusedPort()
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none", _, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
"--store.type=rpc", "--store.rpc.api=http://127.0.0.1", "--store.type=rpc", "--store.rpc.api=http://127.0.0.1",
"--port=" + strconv.Itoa(port), "--admin.type=rpc", "--admin.rpc.api=http://127.0.0.1", "--avatar.fs.path=/tmp"}) "--port=" + strconv.Itoa(port), "--avatar.fs.path=/tmp",
"--admin.type=rpc", "--admin.rpc.secret_per_site", "--admin.rpc.api=http://127.0.0.1"})
require.NoError(t, err) require.NoError(t, err)
opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid" opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid"
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp" opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
@@ -351,6 +352,7 @@ func TestServerApp_Failed(t *testing.T) {
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com") assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
t.Log(err) t.Log(err)
// wrong store type
opts = ServerCommand{} opts = ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"}) opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
@@ -374,6 +376,19 @@ func TestServerApp_Failed(t *testing.T) {
"problem subscribing to channel remark42-cache on address wrong_address: "+ "problem subscribing to channel remark42-cache on address wrong_address: "+
"dial tcp: address wrong_address: missing port in address") "dial tcp: address wrong_address: missing port in address")
t.Log(err) t.Log(err)
// wrong apple private key type
opts = ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
p = flags.NewParser(&opts, flags.Default)
_, err = p.ParseArgs([]string{"--auth.apple.cid=123", "--auth.apple.tid=123",
"--auth.apple.kid=123", "--auth.apple.private-key-filepath=testdata/apple-bad.p8"})
assert.NoError(t, err)
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err,
"failed to make authenticator: an AppleProvider creating failed: "+
"provided private key is not ECDSA")
t.Log(err)
} }
func TestServerApp_Shutdown(t *testing.T) { func TestServerApp_Shutdown(t *testing.T) {
@@ -442,6 +457,8 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
"--notify.telegram.token=abcd", "--notify.telegram.token=abcd",
"--notify.telegram.timeout=3m", "--notify.telegram.timeout=3m",
"--notify.telegram.api=http://example.org", "--notify.telegram.api=http://example.org",
"--auth.twitter.cid=123",
"--auth.twitter.csec=456",
} }
assert.Empty(t, s.SMTP.Host) assert.Empty(t, s.SMTP.Host)
assert.Empty(t, s.SMTP.Port) assert.Empty(t, s.SMTP.Port)
@@ -467,6 +484,8 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
{Old: "notify.telegram.token", New: "telegram.token", Version: "1.9"}, {Old: "notify.telegram.token", New: "telegram.token", Version: "1.9"},
{Old: "notify.telegram.timeout", New: "telegram.timeout", Version: "1.9"}, {Old: "notify.telegram.timeout", New: "telegram.timeout", Version: "1.9"},
{Old: "notify.telegram.api", Version: "1.9"}, {Old: "notify.telegram.api", Version: "1.9"},
{Old: "auth.twitter.cid", Version: "1.14"},
{Old: "auth.twitter.csec", Version: "1.14"},
}, },
deprecatedFlags) deprecatedFlags)
assert.Equal(t, "smtp.example.org", s.SMTP.Host) assert.Equal(t, "smtp.example.org", s.SMTP.Host)
@@ -597,7 +616,7 @@ func TestServerAuthHooks(t *testing.T) {
NotBefore: time.Now().Add(-1 * time.Minute).Unix(), NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
}, },
User: &token.User{ User: &token.User{
ID: "dev", ID: "github_dev",
Name: "developer one", Name: "developer one",
}, },
} }
@@ -636,14 +655,14 @@ func TestServerAuthHooks(t *testing.T) {
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without aud claim rejected, \n"+tkNoAud+"\n"+string(body)) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without aud claim rejected, \n"+tkNoAud+"\n"+string(body))
// block user dev as admin // block user github_dev as admin
req, err = http.NewRequest(http.MethodPut, req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/dev?site=remark&block=1&ttl=10d", port), http.NoBody) fmt.Sprintf("http://localhost:%d/api/v1/admin/user/github_dev?site=remark&block=1&ttl=10d", port), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
req.SetBasicAuth("admin", "password") req.SetBasicAuth("admin", "password")
resp, err = client.Do(req) resp, err = client.Do(req)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode, "user dev blocked") assert.Equal(t, http.StatusOK, resp.StatusCode, "user github_dev blocked")
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
@@ -711,6 +730,28 @@ func Test_splitAtCommas(t *testing.T) {
} }
} }
func Test_getAllowedDomains(t *testing.T) {
tbl := []struct {
s ServerCommand
allowedDomains []string
}{
// correct example, parsed and returned as allowed domain
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "https://remark42.example.org"}}, []string{"example.org"}},
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "http://remark42.example.org"}}, []string{"example.org"}},
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "http://localhost"}}, []string{"localhost"}},
// incorrect URLs, so Hostname is empty but returned list doesn't include empty string as it would allow any domain
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "bad hostname"}}, []string{}},
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "not_a_hostname"}}, []string{}},
// test removal of 'self', multiple AllowedHosts. No deduplication is expected
{ServerCommand{AllowedHosts: []string{"'self'", "example.org", "test.example.org", "remark42.com"}, CommonOpts: CommonOpts{RemarkURL: "https://example.org"}}, []string{"example.org", "test.example.org", "remark42.com", "example.org"}},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
assert.Equal(t, tt.allowedDomains, tt.s.getAllowedDomains())
})
}
}
func chooseRandomUnusedPort() (port int) { func chooseRandomUnusedPort() (port int) {
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
port = 40000 + int(rand.Int31n(10000)) port = 40000 + int(rand.Int31n(10000))
@@ -758,6 +799,8 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp/remark42_test", "fs", "/tmp/remark42_test", "/tmp/remark42_test" cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp/remark42_test", "fs", "/tmp/remark42_test", "/tmp/remark42_test"
cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port) cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port)
cmd.Store.Bolt.Timeout = 10 * time.Second cmd.Store.Bolt.Timeout = 10 * time.Second
cmd.Auth.Apple.CID, cmd.Auth.Apple.KID, cmd.Auth.Apple.TID = "cid", "kid", "tid"
cmd.Auth.Apple.PrivateKeyFilePath = "testdata/apple.p8"
cmd.Auth.Github.CSEC, cmd.Auth.Github.CID = "csec", "cid" cmd.Auth.Github.CSEC, cmd.Auth.Github.CID = "csec", "cid"
cmd.Auth.Google.CSEC, cmd.Auth.Google.CID = "csec", "cid" cmd.Auth.Google.CSEC, cmd.Auth.Google.CID = "csec", "cid"
cmd.Auth.Facebook.CSEC, cmd.Auth.Facebook.CID = "csec", "cid" cmd.Auth.Facebook.CSEC, cmd.Auth.Facebook.CID = "csec", "cid"
@@ -804,12 +847,15 @@ func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Cont
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
app, err := cmd.newServerApp(ctx) app, err := cmd.newServerApp(ctx)
require.NoError(t, err) require.NoError(t, err)
rand.Seed(time.Now().UnixNano())
return app, ctx, cancel return app, ctx, cancel
} }
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// ignore is added only for GitHub Actions, can't reproduce locally // ignore is added only for GitHub Actions, can't reproduce locally
goleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.(*Server).Shutdown")) goleak.VerifyTestMain(
m,
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
)
} }
+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-----
+7 -5
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
@@ -23,7 +24,8 @@ type Opts struct {
CleanupCmd cmd.CleanupCommand `command:"cleanup"` CleanupCmd cmd.CleanupCommand `command:"cleanup"`
RemapCmd cmd.RemapCommand `command:"remap"` RemapCmd cmd.RemapCommand `command:"remap"`
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"` 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"` 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"` Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
@@ -54,11 +56,11 @@ func main() {
} }
if _, err := p.Parse(); err != nil { 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) os.Exit(0)
} else {
os.Exit(1)
} }
os.Exit(1)
} }
} }
@@ -97,7 +99,7 @@ func getDump() string {
return string(stacktrace[:length]) return string(stacktrace[:length])
} }
// nolint:gochecknoinits // can't avoid it in this place //nolint:gochecknoinits // can't avoid it in this place
func init() { func init() {
// catch SIGQUIT and print stack traces // catch SIGQUIT and print stack traces
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
+3 -1
View File
@@ -64,7 +64,7 @@ func TestMain_WithWebhook(t *testing.T) {
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
var webhookSent int32 var webhookSent int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
atomic.StoreInt32(&webhookSent, 1) atomic.StoreInt32(&webhookSent, 1)
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
@@ -157,5 +157,7 @@ func TestMain(m *testing.M) {
m, m,
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"), goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"), goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
) )
} }
+15 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/url"
"time" "time"
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
@@ -110,16 +111,28 @@ func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
continue 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{ c := store.Comment{
ID: comment.CommentHex, ID: comment.CommentHex,
Locator: store.Locator{ Locator: store.Locator{
URL: comment.Path, URL: commentURL,
SiteID: siteID, SiteID: siteID,
}, },
User: u, User: u,
Text: comment.Markdown, Text: comment.Markdown,
Timestamp: comment.CreationDate, Timestamp: comment.CreationDate,
ParentID: comment.ParentHex, ParentID: parentID,
Imported: true, Imported: true,
} }
+1 -1
View File
@@ -163,7 +163,7 @@ func TestNative_ImportManyWithError(t *testing.T) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n") buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n")
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
buf.WriteString(fmt.Sprintf(goodRec, i)) fmt.Fprintf(buf, goodRec, i)
} }
buf.WriteString("{}\n") buf.WriteString("{}\n")
buf.WriteString("{}\n") buf.WriteString("{}\n")
+3 -3
View File
@@ -4,7 +4,7 @@
{ {
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945", "commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
"domain": "example.com", "domain": "example.com",
"url": "https://example.com/blog/post/1", "url": "/blog/post/1",
"commenterHex": "anonymous", "commenterHex": "anonymous",
"markdown": "Example comment created by user.", "markdown": "Example comment created by user.",
"html": "", "html": "",
@@ -18,7 +18,7 @@
{ {
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854", "commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"domain": "example.com", "domain": "example.com",
"url": "https://example.com/blog/post/1", "url": "/blog/post/1",
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10", "commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"markdown": "Example 2 comment created by user.", "markdown": "Example 2 comment created by user.",
"html": "", "html": "",
@@ -32,7 +32,7 @@
{ {
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9", "commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
"domain": "example.com", "domain": "example.com",
"url": "https://example.com/blog/post/1", "url": "/blog/post/1",
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061", "commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
"markdown": "Great reply!", "markdown": "Great reply!",
"html": "", "html": "",
+3 -2
View File
@@ -16,7 +16,8 @@ const wpTimeLayout = "2006-01-02 15:04:05"
// WordPress implements Importer from WP xml // WordPress implements Importer from WP xml
type WordPress struct { type WordPress struct {
DataStore Store DataStore Store
DisableFancyTextFormatting bool
} }
type wpItem struct { type wpItem struct {
@@ -138,7 +139,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
ParentID: comment.PID, ParentID: comment.PID,
Imported: true, Imported: true,
} }
commentsCh <- commentFormatter.Format(c) commentsCh <- commentFormatter.Format(c, w.DisableFancyTextFormatting)
stats.inpComments++ stats.inpComments++
if stats.inpComments%1000 == 0 { if stats.inpComments%1000 == 0 {
log.Printf("[DEBUG] processed %d comments", stats.inpComments) log.Printf("[DEBUG] processed %d comments", stats.inpComments)
+15 -3
View File
@@ -24,7 +24,7 @@ func TestWordPress_Import(t *testing.T) {
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")} dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close() defer dataStore.Close()
wp := WordPress{DataStore: &dataStore} wp := WordPress{DataStore: &dataStore, DisableFancyTextFormatting: false}
size, err := wp.Import(strings.NewReader(xmlTestWP), siteID) size, err := wp.Import(strings.NewReader(xmlTestWP), siteID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 3, size) assert.Equal(t, 3, size)
@@ -41,7 +41,7 @@ func TestWordPress_Import(t *testing.T) {
assert.Equal(t, "e8b1e92bbcf5b9bb88472f9bdb82d1b8c7ed39d6", c.User.IP) assert.Equal(t, "e8b1e92bbcf5b9bb88472f9bdb82d1b8c7ed39d6", c.User.IP)
ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14") ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14")
assert.Equal(t, ts, c.Timestamp) 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) assert.True(t, c.Imported)
posts, err := dataStore.List(siteID, 0, 0) posts, err := dataStore.List(siteID, 0, 0)
@@ -54,6 +54,18 @@ func TestWordPress_Import(t *testing.T) {
count, err := dataStore.Count(store.Locator{URL: "https://realmenweardress.es/2010/07/do-you-rp/", SiteID: siteID}) count, err := dataStore.Count(store.Locator{URL: "https://realmenweardress.es/2010/07/do-you-rp/", SiteID: siteID})
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 3, count) 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) { func TestWordPress_Convert(t *testing.T) {
@@ -247,7 +259,7 @@ var xmlTestWP = `
<wp:comment_author_IP><![CDATA[128.243.253.117]]></wp:comment_author_IP> <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><![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_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_approved><![CDATA[1]]></wp:comment_approved>
<wp:comment_type><![CDATA[]]></wp:comment_type> <wp:comment_type><![CDATA[]]></wp:comment_type>
<wp:comment_parent>13</wp:comment_parent> <wp:comment_parent>13</wp:comment_parent>
+6
View File
@@ -143,6 +143,12 @@ func (s *Service) SubmitVerification(req VerificationRequest) {
// Close queue channel and wait for completion // Close queue channel and wait for completion
func (s *Service) Close() { func (s *Service) Close() {
if s.queue != nil { 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") log.Print("[DEBUG] close notifier")
close(s.queue) close(s.queue)
close(s.verificationQueue) close(s.verificationQueue)
+2
View File
@@ -21,6 +21,8 @@ func TestService_NoDestinations(t *testing.T) {
s.Submit(Request{Comment: store.Comment{ID: "123"}}) s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}}) s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Close() s.Close()
// second call should not result in panic
s.Close()
} }
func TestService_WithDestinations(t *testing.T) { func TestService_WithDestinations(t *testing.T) {
+12 -2
View File
@@ -3,6 +3,7 @@ package notify
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"text/template" "text/template"
"time" "time"
@@ -12,7 +13,7 @@ import (
) )
const ( const (
webhookDefaultTemplate = `{"text": "{{.Text}}"}` webhookDefaultTemplate = `{"text": {{.Text | escapeJSONString}}}`
) )
// WebhookParams contain settings for webhook notifications // WebhookParams contain settings for webhook notifications
@@ -49,7 +50,7 @@ func NewWebhook(params WebhookParams) (*Webhook, error) {
params.Template = webhookDefaultTemplate params.Template = webhookDefaultTemplate
} }
payloadTmpl, err := template.New("webhook").Parse(params.Template) payloadTmpl, err := template.New("webhook").Funcs(template.FuncMap{"escapeJSONString": escapeJSONString}).Parse(params.Template)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse webhook template: %w", err) return nil, fmt.Errorf("unable to parse webhook template: %w", err)
} }
@@ -82,3 +83,12 @@ func (w *Webhook) SendVerification(_ context.Context, _ VerificationRequest) err
func (w *Webhook) String() string { func (w *Webhook) String() string {
return fmt.Sprintf("%s to %s", w.Webhook.String(), w.url) 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
}
+29
View File
@@ -2,6 +2,9 @@ package notify
import ( import (
"context" "context"
"io"
"net/http"
"net/http/httptest"
"testing" "testing"
"time" "time"
@@ -34,6 +37,32 @@ func TestWebhook_NewWebhook(t *testing.T) {
assert.Contains(t, err.Error(), "unable to parse webhook template") 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) { func TestWebhook_Send(t *testing.T) {
wh, err := NewWebhook(WebhookParams{ wh, err := NewWebhook(WebhookParams{
URL: "bad-url", URL: "bad-url",
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/go-pkgz/auth" "github.com/go-pkgz/auth"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
+18 -7
View File
@@ -14,7 +14,7 @@ import (
"time" "time"
"github.com/go-pkgz/auth/token" "github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -111,7 +111,7 @@ func TestAdmin_Title(t *testing.T) {
ts, srv, teardown := startupT(t) ts, srv, teardown := startupT(t)
defer teardown() 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) { tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/post1" { if r.URL.String() == "/post1" {
_, err := w.Write([]byte("<html><title>post1 blah 123</title><body> 2222</body></html>")) _, err := w.Write([]byte("<html><title>post1 blah 123</title><body> 2222</body></html>"))
@@ -348,7 +348,7 @@ func TestAdmin_Block(t *testing.T) {
assert.Equal(t, "test test #1", comments.Comments[2].Text, "comment not removed and not cleared") 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") 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
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
@@ -513,6 +513,7 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0) _, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.Error(t, err) 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") res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&format=tree")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{} comments := commentsWithInfo{}
@@ -521,6 +522,16 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments") assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
assert.True(t, comments.Info.ReadOnly) assert.True(t, comments.Info.ReadOnly)
t.Logf("%+v", comments) 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) { func TestAdmin_ReadOnlyWithAge(t *testing.T) {
@@ -743,9 +754,9 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
defer teardown() defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42", 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", 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) _, err := srv.DataService.Create(c1)
assert.NoError(t, err) assert.NoError(t, err)
@@ -768,13 +779,13 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
SessionOnly: true, SessionOnly: true,
StandardClaims: jwt.StandardClaims{ StandardClaims: jwt.StandardClaims{
Audience: "remark42", Audience: "remark42",
Id: "1234567", Id: "provider1_1234567",
Issuer: "remark42", Issuer: "remark42",
NotBefore: time.Now().Add(-1 * time.Minute).Unix(), NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
}, },
User: &token.User{ User: &token.User{
ID: "user1", ID: "provider1_user1",
Attributes: map[string]interface{}{ Attributes: map[string]interface{}{
"delete_me": true, "delete_me": true,
}, },
+2 -3
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
"github.com/go-chi/render" "github.com/go-chi/render"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
@@ -136,7 +136,6 @@ func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
exportFile := fmt.Sprintf("%s-%s.json.gz", siteID, time.Now().Format("20060102")) exportFile := fmt.Sprintf("%s-%s.json.gz", siteID, time.Now().Format("20060102"))
w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment;filename="+exportFile) w.Header().Set("Content-Disposition", "attachment;filename="+exportFile)
w.WriteHeader(http.StatusOK)
gzWriter := gzip.NewWriter(w) gzWriter := gzip.NewWriter(w)
defer func() { defer func() {
if e := gzWriter.Close(); e != nil { if e := gzWriter.Close(); e != nil {
@@ -163,7 +162,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "remap failed, bad given rules", rest.ErrDecode) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "remap failed, bad given rules", rest.ErrDecode)
return return
} }
defer r.Body.Close() defer r.Body.Close() //nolint gosec // we don't care about response body
// start remap procedure with mapper // start remap procedure with mapper
go func() { go func() {
+163 -1
View File
@@ -9,6 +9,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -49,6 +50,22 @@ func TestMigrator_Import(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
require.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://radio-t.com/blah1")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
} }
func TestMigrator_ImportForm(t *testing.T) { func TestMigrator_ImportForm(t *testing.T) {
@@ -84,6 +101,22 @@ func TestMigrator_ImportForm(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
require.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://radio-t.com/blah1")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
} }
func TestMigrator_ImportFromWP(t *testing.T) { func TestMigrator_ImportFromWP(t *testing.T) {
@@ -108,6 +141,22 @@ func TestMigrator_ImportFromWP(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://realmenweardress.es/2010/07/do-you-rp/")
require.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 3, comments.Info.Count)
require.Equal(t, 3, len(comments.Comments))
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://realmenweardress.es/2010/07/do-you-rp/")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 3, comments.Info.Count)
require.Equal(t, 2, len(comments.Comments), "2 comments with 1 reply")
} }
func TestMigrator_ImportFromCommento(t *testing.T) { func TestMigrator_ImportFromCommento(t *testing.T) {
@@ -115,7 +164,7 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
defer teardown() defer teardown()
r := strings.NewReader(`{"version":1,"comments":[{"commentHex":"7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854", r := strings.NewReader(`{"version":1,"comments":[{"commentHex":"7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"domain":"example.com","url":"https://example.com/blog/post/1","commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10", "domain":"example.com","url":"/blog/post/1","commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"markdown":"Example content","html":"","parentHex":"root","score":0,"state":"approved","creationDate":"2021-03-17T12:09:47.722181Z", "markdown":"Example content","html":"","parentHex":"root","score":0,"state":"approved","creationDate":"2021-03-17T12:09:47.722181Z",
"direction":0,"deleted":false}],"commenters":[{"commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10", "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", "email":"somegreatmail@gmail.com","name":"User5276","link":"https://example.com/profile/257","photo":"https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
@@ -137,6 +186,63 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/blog/post/1")
require.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://example.com/blog/post/1")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
}
func TestMigrator_ImportFromCommentoJSON(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
r, err := os.Open("testdata/commento.json")
require.NoError(t, err)
client := &http.Client{Timeout: 1 * time.Second}
defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
assert.NoError(t, err)
req.Header.Add("Content-Type", "application/json; charset=utf-8")
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "{\"status\":\"import request accepted\"}\n", string(b))
assert.NoError(t, resp.Body.Close())
waitForMigrationCompletion(t, ts)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/example")
require.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 7, comments.Info.Count)
require.Equal(t, 7, len(comments.Comments))
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://example.com/example")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 7, comments.Info.Count)
require.Equal(t, 5, len(comments.Comments), "five comments with two replies")
} }
func TestMigrator_ImportRejected(t *testing.T) { func TestMigrator_ImportRejected(t *testing.T) {
@@ -197,6 +303,20 @@ func TestMigrator_ImportDouble(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusConflict, resp.StatusCode) assert.Equal(t, http.StatusConflict, resp.StatusCode)
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
require.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 50, comments.Info.Count)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 50, comments.Info.Count)
} }
func TestMigrator_ImportWaitExpired(t *testing.T) { func TestMigrator_ImportWaitExpired(t *testing.T) {
@@ -236,6 +356,14 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode) assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/example")
require.Equal(t, http.StatusOK, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 0, comments.Info.Count)
require.Equal(t, 0, len(comments.Comments))
} }
func TestMigrator_Export(t *testing.T) { func TestMigrator_Export(t *testing.T) {
@@ -263,6 +391,16 @@ func TestMigrator_Export(t *testing.T) {
require.Equal(t, http.StatusAccepted, resp.StatusCode) require.Equal(t, http.StatusAccepted, resp.StatusCode)
waitForMigrationCompletion(t, ts) waitForMigrationCompletion(t, ts)
// export wrong site, should result in error
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=test", http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// check file mode // check file mode
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody) req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody)
require.NoError(t, err) require.NoError(t, err)
@@ -339,6 +477,7 @@ func TestMigrator_Remap(t *testing.T) {
err = json.Unmarshal([]byte(res), &comments) err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 2, comments.Info.Count) require.Equal(t, 2, comments.Info.Count)
require.Equal(t, 2, len(comments.Comments))
require.False(t, comments.Info.ReadOnly) require.False(t, comments.Info.ReadOnly)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
@@ -347,6 +486,7 @@ func TestMigrator_Remap(t *testing.T) {
err = json.Unmarshal([]byte(res), &comments) err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count) require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
require.True(t, comments.Info.ReadOnly) require.True(t, comments.Info.ReadOnly)
// we want remap urls to another domain - www.remark42.com // we want remap urls to another domain - www.remark42.com
@@ -364,6 +504,16 @@ func TestMigrator_Remap(t *testing.T) {
err = json.Unmarshal([]byte(res), &comments) err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 2, comments.Info.Count) require.Equal(t, 2, comments.Info.Count)
require.Equal(t, 2, len(comments.Comments))
require.False(t, comments.Info.ReadOnly)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://www.remark42.com/demo/")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 2, comments.Info.Count)
require.Equal(t, 2, len(comments.Comments))
require.False(t, comments.Info.ReadOnly) require.False(t, comments.Info.ReadOnly)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://www.remark42.com/demo-another/") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://www.remark42.com/demo-another/")
@@ -372,6 +522,16 @@ func TestMigrator_Remap(t *testing.T) {
err = json.Unmarshal([]byte(res), &comments) err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count) require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
require.True(t, comments.Info.ReadOnly)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://www.remark42.com/demo-another/")
require.Equal(t, http.StatusOK, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.Equal(t, 1, len(comments.Comments))
require.True(t, comments.Info.ReadOnly) require.True(t, comments.Info.ReadOnly)
// should find nothing from previous url // should find nothing from previous url
@@ -381,6 +541,7 @@ func TestMigrator_Remap(t *testing.T) {
err = json.Unmarshal([]byte(res), &comments) err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 0, comments.Info.Count) require.Equal(t, 0, comments.Info.Count)
require.Equal(t, 0, len(comments.Comments))
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
require.Equal(t, http.StatusOK, code) require.Equal(t, http.StatusOK, code)
@@ -388,6 +549,7 @@ func TestMigrator_Remap(t *testing.T) {
err = json.Unmarshal([]byte(res), &comments) err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 0, comments.Info.Count) require.Equal(t, 0, comments.Info.Count)
require.Equal(t, 0, len(comments.Comments))
} }
func TestMigrator_RemapReject(t *testing.T) { func TestMigrator_RemapReject(t *testing.T) {
+96 -81
View File
@@ -22,7 +22,7 @@ import (
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/go-pkgz/auth" "github.com/go-pkgz/auth"
"github.com/go-pkgz/lcw" "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/go-pkgz/rest/logger" "github.com/go-pkgz/rest/logger"
@@ -59,26 +59,29 @@ type Rest struct {
Low int Low int
Critical int Critical int
} }
UpdateLimiter float64 UpdateLimiter float64
EmailNotifications bool EmailNotifications bool
TelegramBotUsername string TelegramNotifications bool
EmojiEnabled bool EmojiEnabled bool
SimpleView bool SimpleView bool
ProxyCORS bool ProxyCORS bool
SendJWTHeader bool SendJWTHeader bool
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..." AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
SubscribersOnly bool SubscribersOnly bool
DisableSignature bool // prevent signature from being added to headers DisableSignature bool // prevent signature from being added to headers
DisableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
ExternalImageProxy bool
SSLConfig SSLConfig SSLConfig SSLConfig
httpsServer *http.Server httpsServer *http.Server
httpServer *http.Server httpServer *http.Server
lock sync.Mutex lock sync.Mutex
pubRest public pubRest public
privRest private privRest private
adminRest admin adminRest admin
rssRest rss rssRest rss
openRouteLimiter float64
} }
// LoadingCache defines interface for caching // LoadingCache defines interface for caching
@@ -89,7 +92,7 @@ type LoadingCache interface {
} }
const hardBodyLimit = 1024 * 64 // limit size of body const hardBodyLimit = 1024 * 64 // limit size of body
const openRouteLimiter = 10 // limit for open routes
const lastCommentsScope = "last" const lastCommentsScope = "last"
type commentsWithInfo struct { type commentsWithInfo struct {
@@ -97,6 +100,11 @@ type commentsWithInfo struct {
Info store.PostInfo `json:"info,omitempty"` Info store.PostInfo `json:"info,omitempty"`
} }
type treeWithInfo struct {
*service.Tree
Info store.PostInfo `json:"info,omitempty"`
}
// Run the lister and request's router, activate rest server // Run the lister and request's router, activate rest server
func (s *Rest) Run(address string, port int) { func (s *Rest) Run(address string, port int) {
if address == "*" { if address == "*" {
@@ -192,8 +200,13 @@ func (s *Rest) makeHTTPServer(address string, port int, router http.Handler) *ht
} }
func (s *Rest) routes() chi.Router { func (s *Rest) routes() chi.Router {
if s.openRouteLimiter == 0 {
// set the default open route limiter. Just a safety measure as it should be set by Run method anyway
s.openRouteLimiter = openRouteLimiter
}
router := chi.NewRouter() router := chi.NewRouter()
router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default())) router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default()))
router.Use(securityHeadersMiddleware(s.ExternalImageProxy, s.AllowedAncestors))
if !s.DisableSignature { if !s.DisableSignature {
router.Use(R.AppInfo("remark42", "umputun", s.Version)) router.Use(R.AppInfo("remark42", "umputun", s.Version))
} }
@@ -215,11 +228,6 @@ func (s *Rest) routes() chi.Router {
router.Use(corsMiddleware.Handler) router.Use(corsMiddleware.Handler)
} }
if len(s.AllowedAncestors) > 0 {
log.Printf("[INFO] allowed from %+v only", s.AllowedAncestors)
router.Use(frameAncestors(s.AllowedAncestors))
}
ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization
logInfoWithBody := logger.New(logger.Log(log.Default()), logger.WithBody, logger.IPfn(ipFn), logger.Prefix("[INFO]")).Handler logInfoWithBody := logger.New(logger.Log(log.Default()), logger.WithBody, logger.IPfn(ipFn), logger.Prefix("[INFO]")).Handler
@@ -251,7 +259,7 @@ func (s *Rest) routes() chi.Router {
// open routes // open routes
rapi.Group(func(ropen chi.Router) { rapi.Group(func(ropen chi.Router) {
ropen.Use(middleware.Timeout(30 * time.Second)) ropen.Use(middleware.Timeout(30 * time.Second))
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.openRouteLimiter, nil)))
ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody) ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody)
ropen.Get("/config", s.configCtrl) ropen.Get("/config", s.configCtrl)
ropen.Get("/find", s.pubRest.findCommentsCtrl) ropen.Get("/find", s.pubRest.findCommentsCtrl)
@@ -369,16 +377,17 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
} }
privGrp := private{ privGrp := private{
dataService: s.DataService, dataService: s.DataService,
cache: s.Cache, cache: s.Cache,
imageService: s.ImageService, imageService: s.ImageService,
commentFormatter: s.CommentFormatter, commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge, readOnlyAge: s.ReadOnlyAge,
authenticator: s.Authenticator, authenticator: s.Authenticator,
notifyService: s.NotifyService, notifyService: s.NotifyService,
telegramService: s.TelegramService, telegramService: s.TelegramService,
remarkURL: s.RemarkURL, remarkURL: s.RemarkURL,
anonVote: s.AnonVote, anonVote: s.AnonVote,
disableFancyTextFormatting: s.DisableFancyTextFormatting,
} }
admGrp := admin{ admGrp := admin{
@@ -414,44 +423,46 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
emails, _ := s.DataService.AdminStore.Email(siteID) emails, _ := s.DataService.AdminStore.Email(siteID)
cnf := struct { cnf := struct {
Version string `json:"version"` Version string `json:"version"`
EditDuration int `json:"edit_duration"` EditDuration int `json:"edit_duration"`
AdminEdit bool `json:"admin_edit"` AdminEdit bool `json:"admin_edit"`
MaxCommentSize int `json:"max_comment_size"` MinCommentSize int `json:"min_comment_size"`
Admins []string `json:"admins"` MaxCommentSize int `json:"max_comment_size"`
AdminEmail string `json:"admin_email"` Admins []string `json:"admins"`
Auth []string `json:"auth_providers"` AdminEmail string `json:"admin_email"`
AnonVote bool `json:"anon_vote"` Auth []string `json:"auth_providers"`
LowScore int `json:"low_score"` AnonVote bool `json:"anon_vote"`
CriticalScore int `json:"critical_score"` LowScore int `json:"low_score"`
PositiveScore bool `json:"positive_score"` CriticalScore int `json:"critical_score"`
ReadOnlyAge int `json:"readonly_age"` PositiveScore bool `json:"positive_score"`
MaxImageSize int `json:"max_image_size"` ReadOnlyAge int `json:"readonly_age"`
EmailNotifications bool `json:"email_notifications"` MaxImageSize int `json:"max_image_size"`
TelegramBotUsername string `json:"telegram_bot_username"` EmailNotifications bool `json:"email_notifications"`
EmojiEnabled bool `json:"emoji_enabled"` TelegramNotifications bool `json:"telegram_notifications"`
SimpleView bool `json:"simple_view"` EmojiEnabled bool `json:"emoji_enabled"`
SendJWTHeader bool `json:"send_jwt_header"` SimpleView bool `json:"simple_view"`
SubscribersOnly bool `json:"subscribers_only"` SendJWTHeader bool `json:"send_jwt_header"`
SubscribersOnly bool `json:"subscribers_only"`
}{ }{
Version: s.Version, Version: s.Version,
EditDuration: int(s.DataService.EditDuration.Seconds()), EditDuration: int(s.DataService.EditDuration.Seconds()),
AdminEdit: s.DataService.AdminEdits, AdminEdit: s.DataService.AdminEdits,
MaxCommentSize: s.DataService.MaxCommentSize, MinCommentSize: s.DataService.MinCommentSize,
Admins: admins, MaxCommentSize: s.DataService.MaxCommentSize,
AdminEmail: emails, Admins: admins,
LowScore: s.ScoreThresholds.Low, AdminEmail: emails,
CriticalScore: s.ScoreThresholds.Critical, LowScore: s.ScoreThresholds.Low,
PositiveScore: s.DataService.PositiveScore, CriticalScore: s.ScoreThresholds.Critical,
ReadOnlyAge: s.ReadOnlyAge, PositiveScore: s.DataService.PositiveScore,
MaxImageSize: s.ImageService.MaxSize, ReadOnlyAge: s.ReadOnlyAge,
EmailNotifications: s.EmailNotifications, MaxImageSize: s.ImageService.MaxSize,
TelegramBotUsername: s.TelegramBotUsername, EmailNotifications: s.EmailNotifications,
EmojiEnabled: s.EmojiEnabled, TelegramNotifications: s.TelegramNotifications,
AnonVote: s.AnonVote, EmojiEnabled: s.EmojiEnabled,
SimpleView: s.SimpleView, AnonVote: s.AnonVote,
SendJWTHeader: s.SendJWTHeader, SimpleView: s.SimpleView,
SubscribersOnly: s.SubscribersOnly, SendJWTHeader: s.SendJWTHeader,
SubscribersOnly: s.SubscribersOnly,
} }
cnf.Auth = []string{} cnf.Auth = []string{}
@@ -609,19 +620,23 @@ func cacheControl(expiration time.Duration, version string) func(http.Handler) h
} }
} }
// frameAncestors is a middleware setting Content-Security-Policy "frame-ancestors host1 host2 ..." // securityHeadersMiddleware sets security-related headers: Content-Security-Policy and Permissions-Policy
// prevents loading of comments widgets from any other origins. In case if the list of allowed empty, ignored. func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
func frameAncestors(hosts []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fn := func(w http.ResponseWriter, r *http.Request) { imgSrc := "*"
if len(hosts) == 0 { if imageProxyEnabled {
h.ServeHTTP(w, r) imgSrc = "'self'"
return
} }
w.Header().Set("Content-Security-Policy", "frame-ancestors "+strings.Join(hosts, " ")+";") frameAncestors := "*"
h.ServeHTTP(w, r) if len(allowedAncestors) > 0 {
} log.Printf("[INFO] frame embedding allowed from %+v only", allowedAncestors)
return http.HandlerFunc(fn) frameAncestors = strings.Join(allowedAncestors, " ")
}
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self'; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src data:; 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=()")
next.ServeHTTP(w, r)
})
} }
} }
+94 -46
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/sha1" //nolint:gosec //not used for security "crypto/sha1" //nolint:gosec //not used for security
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
@@ -17,7 +18,7 @@ import (
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/go-pkgz/auth" "github.com/go-pkgz/auth"
"github.com/go-pkgz/auth/token" "github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
@@ -33,16 +34,17 @@ import (
) )
type private struct { type private struct {
dataService privStore dataService privStore
cache LoadingCache cache LoadingCache
readOnlyAge int readOnlyAge int
commentFormatter *store.CommentFormatter commentFormatter *store.CommentFormatter
imageService *image.Service imageService *image.Service
notifyService *notify.Service notifyService *notify.Service
authenticator *auth.Service authenticator *auth.Service
telegramService telegramService telegramService telegramService
remarkURL string remarkURL string
anonVote bool anonVote bool
disableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
} }
// telegramService is a subset of Telegram service used for setting up user telegram notifications // telegramService is a subset of Telegram service used for setting up user telegram notifications
@@ -87,14 +89,14 @@ func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
return return
} }
comment = s.commentFormatter.Format(comment) comment = s.commentFormatter.Format(comment, s.disableFancyTextFormatting)
comment.Sanitize() comment.Sanitize()
// check if images are valid // check if images are valid, omit proxied images as they are lazy-loaded
for _, id := range s.imageService.ExtractPictures(comment.Text) { for _, id := range s.imageService.ExtractNonProxiedPictures(comment.Text) {
err := s.imageService.ResetCleanupTimer(id) err := s.imageService.ResetCleanupTimer(id)
if err != nil { if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't renew staged picture cleanup timer", rest.ErrImgNotFound) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound)
return return
} }
} }
@@ -127,10 +129,10 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
return return
} }
comment = s.commentFormatter.Format(comment) comment = s.commentFormatter.Format(comment, s.disableFancyTextFormatting)
// check if images are valid // check if images are valid, omit proxied images as they are lazy-loaded
for _, id := range s.imageService.ExtractPictures(comment.Text) { for _, id := range s.imageService.ExtractNonProxiedPictures(comment.Text) {
_, err := s.imageService.Load(id) _, err := s.imageService.Load(id)
if err != nil { if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound)
@@ -150,7 +152,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
} }
id, err := s.dataService.Create(comment) id, err := s.dataService.Create(comment)
if err == service.ErrRestrictedWordsFound { if errors.Is(err, service.ErrRestrictedWordsFound) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords)
return return
} }
@@ -187,7 +189,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
}{} }{}
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &edit); err != nil { if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &edit); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't read comment details from body", rest.ErrDecode)
return return
} }
@@ -211,7 +213,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
} }
editReq := service.EditRequest{ editReq := service.EditRequest{
Text: s.commentFormatter.FormatText(edit.Text), Text: s.commentFormatter.FormatText(edit.Text, s.disableFancyTextFormatting),
Orig: edit.Text, Orig: edit.Text,
Summary: edit.Summary, Summary: edit.Summary,
Delete: edit.Delete, Delete: edit.Delete,
@@ -219,7 +221,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
} }
res, err := s.dataService.EditComment(locator, id, editReq) res, err := s.dataService.EditComment(locator, id, editReq)
if err == service.ErrRestrictedWordsFound { if errors.Is(err, service.ErrRestrictedWordsFound) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
return return
} }
@@ -244,7 +246,7 @@ func (s *private) userInfoCtrl(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("[WARN] can't read email for %s, %v", user.ID, err) log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
} }
if len(email) > 0 { if email != "" {
user.EmailSubscription = true user.EmailSubscription = true
} }
} }
@@ -307,28 +309,59 @@ func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) {
} }
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user. // sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
// GET /email/subscribe?site=siteID&address=someone@example.com // In case user is logged in with the same email, and auto_confirm is true, confirm it right away.
// In case of quick confirmation, "updated" is set to true, otherwise - to false.
// POST /email/subscribe with site and address in json body
//
//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON //nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON
func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) { func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r) user := rest.MustGetUserInfo(r)
address := r.URL.Query().Get("address")
siteID := r.URL.Query().Get("site") subscribe := struct {
if address == "" { Site string
Address string
autoConfirm bool
}{autoConfirm: true}
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &subscribe); err != nil {
if err != io.EOF {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
return
}
// old behavior fallback, reading from the query params. Auto confirm is false in this case.
subscribe.Address = r.URL.Query().Get("address")
subscribe.Site = r.URL.Query().Get("site")
subscribe.autoConfirm = false
}
if subscribe.Address == "" {
rest.SendErrorJSON(w, r, http.StatusBadRequest, rest.SendErrorJSON(w, r, http.StatusBadRequest,
fmt.Errorf("missing parameter"), "address parameter is required", rest.ErrInternal) fmt.Errorf("missing parameter"), "address parameter is required", rest.ErrInternal)
return return
} }
existingAddress, err := s.dataService.GetUserEmail(siteID, user.ID) existingAddress, getErr := s.dataService.GetUserEmail(subscribe.Site, user.ID)
if err != nil { if getErr != nil {
log.Printf("[WARN] can't read email for %s, %v", user.ID, err) log.Printf("[WARN] can't read email for %s, %v", user.ID, getErr)
} }
if address == existingAddress { if subscribe.Address == existingAddress {
rest.SendErrorJSON(w, r, http.StatusConflict, rest.SendErrorJSON(w, r, http.StatusConflict,
fmt.Errorf("already verified"), "email address is already verified for this user", rest.ErrInternal) fmt.Errorf("already verified"), "email address is already verified for this user", rest.ErrInternal)
return return
} }
// in case the user logged in with the same email as they try to subscribe with, confirm it right away
// this behavior is different from the previous one and is hidden behind the autoConfirm flag,
// which is true for the new API, and false for the old one
//
// nolint:gosec // this is not used for security purposes
if subscribe.autoConfirm &&
strings.HasPrefix(user.ID, "email_") &&
strings.TrimPrefix(user.ID, "email_") == token.HashID(sha1.New(), subscribe.Address) {
s.setEmail(w, r, user.ID, subscribe.Site, subscribe.Address)
return
}
claims := token.Claims{ claims := token.Claims{
Handshake: &token.Handshake{ID: user.ID + "::" + address}, Handshake: &token.Handshake{ID: user.ID + "::" + subscribe.Address},
StandardClaims: jwt.StandardClaims{ StandardClaims: jwt.StandardClaims{
Audience: r.URL.Query().Get("site"), Audience: r.URL.Query().Get("site"),
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
@@ -345,14 +378,14 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
s.notifyService.SubmitVerification( s.notifyService.SubmitVerification(
notify.VerificationRequest{ notify.VerificationRequest{
SiteID: siteID, SiteID: subscribe.Site,
User: user.Name, User: user.Name,
Email: address, Email: subscribe.Address,
Token: tkn, Token: tkn,
}, },
) )
render.JSON(w, r, R.JSON{"user": user, "address": address}) render.JSON(w, r, R.JSON{"user": user, "address": subscribe.Address, "updated": false})
} }
// telegramSubscribeCtrl generates and verifies telegram notification request // telegramSubscribeCtrl generates and verifies telegram notification request
@@ -399,7 +432,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
var address, siteID string var address, siteID string
address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID) address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID)
if err != nil { if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't set telegram for user", rest.ErrInternal) rest.SendErrorJSON(w, r, http.StatusNotFound, err, "request is not verified yet", rest.ErrInternal)
return return
} }
@@ -416,17 +449,29 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
} }
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token // setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
// PUT /email/confirm?site=siteID&tkn=jwt // POST /email/confirm with site and token in json body
func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) { func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) {
tkn := r.URL.Query().Get("tkn") user := rest.MustGetUserInfo(r)
if tkn == "" {
confirm := struct {
Site string
Token string
}{}
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &confirm); err != nil {
if err != io.EOF {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
return
}
// old behavior fallback, reading from the query params
confirm.Token = r.URL.Query().Get("tkn")
confirm.Site = r.URL.Query().Get("site")
}
if confirm.Token == "" {
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("missing parameter"), "token parameter is required", rest.ErrInternal) rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("missing parameter"), "token parameter is required", rest.ErrInternal)
return return
} }
user := rest.MustGetUserInfo(r) confClaims, err := s.authenticator.TokenService().Parse(confirm.Token)
siteID := r.URL.Query().Get("site")
confClaims, err := s.authenticator.TokenService().Parse(tkn)
if err != nil { if err != nil {
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal) rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
return return
@@ -444,17 +489,20 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
return return
} }
address := elems[1] address := elems[1]
s.setEmail(w, r, user.ID, confirm.Site, address)
}
log.Printf("[DEBUG] set email for user %s", user.ID) func (s *private) setEmail(w http.ResponseWriter, r *http.Request, userID, siteID, address string) {
log.Printf("[DEBUG] set email for user %s", userID)
val, err := s.dataService.SetUserEmail(siteID, user.ID, address) val, err := s.dataService.SetUserEmail(siteID, userID, address)
if err != nil { if err != nil {
code := parseError(err, rest.ErrInternal) code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code)
return return
} }
// update User.Email from the token // update User.Email field
claims, _, err := s.authenticator.TokenService().Get(r) claims, _, err := s.authenticator.TokenService().Get(r)
if err != nil { if err != nil {
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal) rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
+299 -34
View File
@@ -10,6 +10,7 @@ import (
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -24,6 +25,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/notify" "github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/rest/proxy"
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/image" "github.com/umputun/remark42/backend/app/store/image"
) )
@@ -69,7 +71,7 @@ func TestRest_CreateFilteredCode(t *testing.T) {
c := R.JSON{} c := R.JSON{}
err = json.Unmarshal(b, &c) err = json.Unmarshal(b, &c)
assert.NoError(t, err) require.NoError(t, err, string(b))
loc := c["locator"].(map[string]interface{}) loc := c["locator"].(map[string]interface{})
assert.Equal(t, "remark42", loc["site"]) assert.Equal(t, "remark42", loc["site"])
assert.Equal(t, "https://radio-t.com/blah1", loc["url"]) assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
@@ -79,6 +81,86 @@ func TestRest_CreateFilteredCode(t *testing.T) {
assert.True(t, len(c["id"].(string)) > 8) assert.True(t, len(c["id"].(string)) > 8)
} }
// based on issue https://github.com/umputun/remark42/issues/1631
func TestRest_CreateAndPreviewWithImage(t *testing.T) {
ts, srv, teardown := startupT(t)
ts.Close()
defer teardown()
srv.ImageService.ProxyAPI = srv.RemarkURL + "/api/v1/img"
srv.ImageProxy = &proxy.Image{
HTTP2HTTPS: true,
CacheExternal: true,
RoutePath: "/api/v1/img",
RemarkURL: srv.RemarkURL,
ImageService: srv.ImageService,
}
srv.CommentFormatter = store.NewCommentFormatter(srv.ImageProxy)
// need to recreate the server with new ImageProxy, otherwise old one will be used
ts = httptest.NewServer(srv.routes())
defer ts.Close()
var pngRead bool
// server with the test PNG image
pngServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, e := io.Copy(w, gopherPNG())
assert.NoError(t, e)
pngRead = true
}))
defer pngServer.Close()
t.Run("create", func(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/comment",
`{"text": "![](`+pngServer.URL+`/gopher.png)", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
assert.NoError(t, err)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode, string(b))
assert.NoError(t, resp.Body.Close())
c := R.JSON{}
err = json.Unmarshal(b, &c)
require.NoError(t, err, string(b))
assert.NotContains(t, c["text"], pngServer.URL)
assert.Contains(t, c["text"], srv.RemarkURL)
loc := c["locator"].(map[string]interface{})
assert.Equal(t, "remark42", loc["site"])
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
assert.True(t, len(c["id"].(string)) > 8)
assert.Equal(t, false, pngRead, "original image is not yet accessed by server")
})
t.Run("preview", func(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/preview",
`{"text": "![](`+pngServer.URL+`/gopher.png)", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
assert.NoError(t, err)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode, string(b))
assert.NoError(t, resp.Body.Close())
assert.NotContains(t, string(b), pngServer.URL)
assert.Contains(t, string(b), srv.RemarkURL)
assert.Equal(t, false, pngRead, "original image is not yet accessed by server")
// retrieve the image from the cache
imgURL := strings.Split(strings.Split(string(b), "src=\"")[1], "\"")[0]
// replace srv.RemarkURL with ts.URL
imgURL = strings.ReplaceAll(imgURL, srv.RemarkURL, ts.URL)
resp, err = http.Get(imgURL)
assert.NoError(t, err)
b, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode, string(b))
assert.NoError(t, resp.Body.Close())
// compare image to original gopher png after decoding from base64
assert.Equal(t, gopher, base64.StdEncoding.EncodeToString(b))
assert.Equal(t, true, pngRead, "original image accessed to be shown to user")
})
}
func TestRest_CreateOldPost(t *testing.T) { func TestRest_CreateOldPost(t *testing.T) {
ts, srv, teardown := startupT(t) ts, srv, teardown := startupT(t)
defer teardown() defer teardown()
@@ -166,6 +248,25 @@ func TestRest_CreateWithRestrictedWord(t *testing.T) {
assert.Equal(t, "invalid comment", c["details"]) assert.Equal(t, "invalid comment", c["details"])
} }
func TestRest_CreateRelativeURL(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
// check that it's not possible to click insert URL button and not alter the URL in it (which is `url` by default)
relativeURLText := `{"text": "here is a link with relative URL: [google.com](url)", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
resp, err := post(t, ts.URL+"/api/v1/comment", relativeURLText)
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())
c := R.JSON{}
err = json.Unmarshal(b, &c)
assert.NoError(t, err)
assert.Equal(t, "links should start with mailto:, http:// or https://", c["error"])
assert.Equal(t, "invalid comment", c["details"])
}
func TestRest_CreateRejected(t *testing.T) { func TestRest_CreateRejected(t *testing.T) {
ts, _, teardown := startupT(t) ts, _, teardown := startupT(t)
defer teardown() defer teardown()
@@ -267,6 +368,56 @@ func TestRest_CreateAndGet(t *testing.T) {
assert.Equal(t, store.User{Name: "admin", ID: "admin", Admin: true, Blocked: false, IP: ""}, comment.User, "no ip") assert.Equal(t, store.User{Name: "admin", ID: "admin", Admin: true, Blocked: false, IP: ""}, comment.User, "no ip")
} }
func TestRest_CreateWithQuotes(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
// create comment with quotes with smartypants
resp, err := post(t, ts.URL+"/api/v1/comment",
`{"text": "smartpants \"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
c := R.JSON{}
err = json.Unmarshal(b, &c)
assert.NoError(t, err)
id := c["id"].(string)
// get created comment by id as non-admin
res, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
assert.Equal(t, http.StatusOK, code)
comment := store.Comment{}
err = json.Unmarshal([]byte(res), &comment)
assert.NoError(t, err)
assert.Equal(t, "<p>smartpants «quoted» text</p>\n", comment.Text)
assert.Equal(t, "smartpants \"quoted\" text", comment.Orig)
// create comment with quotes without smartypants
srv.privRest.disableFancyTextFormatting = true
resp, err = post(t, ts.URL+"/api/v1/comment",
`{"text": "no_smartpants \"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
b, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
c = R.JSON{}
err = json.Unmarshal(b, &c)
assert.NoError(t, err)
id = c["id"].(string)
// get created comment by id as non-admin
res, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
assert.Equal(t, http.StatusOK, code)
comment = store.Comment{}
err = json.Unmarshal([]byte(res), &comment)
assert.NoError(t, err)
assert.Equal(t, "<p>no_smartpants &#34;quoted&#34; text</p>\n", comment.Text)
assert.Equal(t, "no_smartpants \"quoted\" text", comment.Orig)
}
func TestRest_Update(t *testing.T) { func TestRest_Update(t *testing.T) {
ts, _, teardown := startupT(t) ts, _, teardown := startupT(t)
defer teardown() defer teardown()
@@ -574,7 +725,7 @@ func TestRest_Vote(t *testing.T) {
req, err := http.NewRequest(http.MethodPut, req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/vote/%s?site=remark42&url=https://radio-t.com/blah&vote=%d", ts.URL, id1, val), http.NoBody) fmt.Sprintf("%s/api/v1/vote/%s?site=remark42&url=https://radio-t.com/blah&vote=%d", ts.URL, id1, val), http.NoBody)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Add("X-JWT", devToken) req.Header.Add("X-JWT", dev2Token)
resp, err := client.Do(req) resp, err := client.Do(req)
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
@@ -583,7 +734,7 @@ func TestRest_Vote(t *testing.T) {
assert.Equal(t, http.StatusOK, vote(1), "first vote allowed") assert.Equal(t, http.StatusOK, vote(1), "first vote allowed")
assert.Equal(t, http.StatusBadRequest, vote(1), "second vote rejected") assert.Equal(t, http.StatusBadRequest, vote(1), "second vote rejected")
body, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1)) body, code := getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
cr := store.Comment{} cr := store.Comment{}
err := json.Unmarshal([]byte(body), &cr) err := json.Unmarshal([]byte(body), &cr)
@@ -594,7 +745,7 @@ func TestRest_Vote(t *testing.T) {
assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs, "hidden") assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs, "hidden")
assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed") assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed")
body, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1)) body, code = getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
cr = store.Comment{} cr = store.Comment{}
err = json.Unmarshal([]byte(body), &cr) err = json.Unmarshal([]byte(body), &cr)
@@ -603,7 +754,7 @@ func TestRest_Vote(t *testing.T) {
assert.Equal(t, 0, cr.Vote) assert.Equal(t, 0, cr.Vote)
assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed one more time") assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed one more time")
body, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1)) body, code = getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
cr = store.Comment{} cr = store.Comment{}
err = json.Unmarshal([]byte(body), &cr) err = json.Unmarshal([]byte(body), &cr)
@@ -612,7 +763,7 @@ func TestRest_Vote(t *testing.T) {
assert.Equal(t, -1, cr.Vote) assert.Equal(t, -1, cr.Vote)
assert.Equal(t, http.StatusBadRequest, vote(-1), "dbl vote not allowed") assert.Equal(t, http.StatusBadRequest, vote(-1), "dbl vote not allowed")
body, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1)) body, code = getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
cr = store.Comment{} cr = store.Comment{}
err = json.Unmarshal([]byte(body), &cr) err = json.Unmarshal([]byte(body), &cr)
@@ -709,7 +860,7 @@ func TestRest_EmailAndTelegram(t *testing.T) {
// issue good token // issue good token
claims := token.Claims{ claims := token.Claims{
Handshake: &token.Handshake{ID: "dev::good@example.com"}, Handshake: &token.Handshake{ID: "provider1_dev::good@example.com"},
StandardClaims: jwt.StandardClaims{ StandardClaims: jwt.StandardClaims{
Audience: "remark42", Audience: "remark42",
ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), ExpiresAt: time.Now().Add(10 * time.Minute).Unix(),
@@ -728,19 +879,25 @@ func TestRest_EmailAndTelegram(t *testing.T) {
responseCode int responseCode int
noAuth bool noAuth bool
cookieEmail string cookieEmail string
body string
}{ }{
{description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true}, {description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest}, {description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest},
{description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK}, {description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
{description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest}, {description: "set user email, token not set", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
{description: "send email confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest}, {description: "set user email, token not set, old query param", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "send email confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK}, {description: "send email confirmation without address", url: "/api/v1/email/subscribe", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"}, {description: "send email confirmation without address, old query param", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "send email confirmation", url: "/api/v1/email/subscribe", method: http.MethodPost, responseCode: http.StatusOK, body: `{"site":"remark42","address":"good@example.com"}`},
{description: "send email confirmation, old query param", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
{description: "set user email, token is good", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
{description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict}, {description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
{description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK}, {description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
{description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK}, {description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
{description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK}, {description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"}, {description: "set user email, token is good", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
{description: "unsubscribe user, no token", url: "/email/unsubscribe.html?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest}, {description: "unsubscribe user, no token", url: "/email/unsubscribe.html?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "unsubscribe user, wrong token", url: "/email/unsubscribe.html?site=remark42&tkn=jwt", method: http.MethodGet, responseCode: http.StatusForbidden}, {description: "unsubscribe user, wrong token", url: "/email/unsubscribe.html?site=remark42&tkn=jwt", method: http.MethodGet, responseCode: http.StatusForbidden},
{description: "unsubscribe user, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK}, {description: "unsubscribe user, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK},
@@ -761,7 +918,11 @@ func TestRest_EmailAndTelegram(t *testing.T) {
for _, x := range testData { for _, x := range testData {
x := x x := x
t.Run(x.description, func(t *testing.T) { t.Run(x.description, func(t *testing.T) {
req, err := http.NewRequest(x.method, ts.URL+x.url, http.NoBody) reqBody := io.NopCloser(strings.NewReader(x.body))
if x.body == "" {
reqBody = http.NoBody
}
req, err := http.NewRequest(x.method, ts.URL+x.url, reqBody)
require.NoError(t, err) require.NoError(t, err)
if !x.noAuth { if !x.noAuth {
req.Header.Add("X-JWT", devToken) req.Header.Add("X-JWT", devToken)
@@ -798,7 +959,7 @@ func TestRest_EmailNotification(t *testing.T) {
// create new comment from dev user // create new comment from dev user
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader( req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
`{"text": "test 123", `{"text": "test 123",
"user": {"name": "dev::good@example.com"}, "user": {"name": "provider1_dev::good@example.com"},
"locator":{"url": "https://radio-t.com/blah1", "locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`)) "site": "remark42"}}`))
assert.NoError(t, err) assert.NoError(t, err)
@@ -837,7 +998,11 @@ func TestRest_EmailNotification(t *testing.T) {
assert.Empty(t, mockDestination.Get()[1].Emails) assert.Empty(t, mockDestination.Get()[1].Emails)
// send confirmation token for email // send confirmation token for email
req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com", http.NoBody) req, err = http.NewRequest(
http.MethodPost,
ts.URL+"/api/v1/email/subscribe",
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
)
require.NoError(t, err) require.NoError(t, err)
req.Header.Add("X-JWT", devToken) req.Header.Add("X-JWT", devToken)
resp, err = client.Do(req) resp, err = client.Do(req)
@@ -852,8 +1017,31 @@ func TestRest_EmailNotification(t *testing.T) {
assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email) assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email)
verificationToken := mockDestination.GetVerify()[0].Token verificationToken := mockDestination.GetVerify()[0].Token
// get user information to verify lack of the subscription
req, err = http.NewRequest(
http.MethodGet,
ts.URL+"/api/v1/user?site=remark42",
http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err = client.Do(req)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
var clearUser store.User
err = json.Unmarshal(body, &clearUser)
assert.NoError(t, err)
assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev", EmailSubscription: false,
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, clearUser)
// verify email // verify email
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), http.NoBody) req, err = http.NewRequest(
http.MethodPost,
ts.URL+"/api/v1/email/confirm",
io.NopCloser(strings.NewReader(fmt.Sprintf(`{"site": "remark42", "token": %q}`, verificationToken))),
)
require.NoError(t, err) require.NoError(t, err)
req.Header.Add("X-JWT", devToken) req.Header.Add("X-JWT", devToken)
resp, err = client.Do(req) resp, err = client.Do(req)
@@ -864,7 +1052,10 @@ func TestRest_EmailNotification(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// get user information to verify the subscription // get user information to verify the subscription
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/user?site=remark42", http.NoBody) req, err = http.NewRequest(
http.MethodGet,
ts.URL+"/api/v1/user?site=remark42",
http.NoBody)
require.NoError(t, err) require.NoError(t, err)
req.Header.Add("X-JWT", devToken) req.Header.Add("X-JWT", devToken)
resp, err = client.Do(req) resp, err = client.Do(req)
@@ -873,11 +1064,11 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
var user store.User var subscribedUser store.User
err = json.Unmarshal(body, &user) err = json.Unmarshal(body, &subscribedUser)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, store.User{Name: "developer one", ID: "dev", EmailSubscription: true, assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev", EmailSubscription: true,
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user) Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, subscribedUser)
// create child comment from another user, email notification expected // create child comment from another user, email notification expected
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf( req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
@@ -928,6 +1119,80 @@ func TestRest_EmailNotification(t *testing.T) {
time.Sleep(time.Millisecond * 30) time.Sleep(time.Millisecond * 30)
require.Equal(t, 4, len(mockDestination.Get())) require.Equal(t, 4, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[3].Emails) assert.Empty(t, mockDestination.Get()[3].Emails)
// confirm email via subscribe call with query params, old behavior, email notification is expected
req, err = http.NewRequest(
http.MethodPost,
ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com",
http.NoBody,
)
require.NoError(t, err)
req.Header.Add("X-JWT", emailUserToken)
resp, err = client.Do(req)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.GetVerify()), "verification email was sent")
// get email user information to verify there is no subscription yet
req, err = http.NewRequest(
http.MethodGet,
ts.URL+"/api/v1/user?site=remark42",
http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", emailUserToken)
resp, err = client.Do(req)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
var unsubscribedEmailUser store.User
err = json.Unmarshal(body, &unsubscribedEmailUser)
assert.NoError(t, err)
assert.Equal(t, store.User{Name: "good@example.com test user", ID: "email_f5dfe9d2e6bd75fc74ea5fabf273b45b5baeb195", EmailSubscription: false,
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, unsubscribedEmailUser)
// confirm email via subscribe call, no email notification is expected
req, err = http.NewRequest(
http.MethodPost,
ts.URL+"/api/v1/email/subscribe",
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
)
require.NoError(t, err)
req.Header.Add("X-JWT", emailUserToken)
resp, err = client.Do(req)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.GetVerify()), "no new verification email was sent")
// get email user information to verify the subscription happened without the confirmation call
req, err = http.NewRequest(
http.MethodGet,
ts.URL+"/api/v1/user?site=remark42",
http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", emailUserToken)
resp, err = client.Do(req)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
var subscribedEmailUser store.User
err = json.Unmarshal(body, &subscribedEmailUser)
assert.NoError(t, err)
assert.Equal(t, store.User{Name: "good@example.com test user", ID: "email_f5dfe9d2e6bd75fc74ea5fabf273b45b5baeb195", EmailSubscription: true,
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, subscribedEmailUser)
} }
func TestRest_TelegramNotification(t *testing.T) { func TestRest_TelegramNotification(t *testing.T) {
@@ -944,7 +1209,7 @@ func TestRest_TelegramNotification(t *testing.T) {
// create new comment from dev user // create new comment from dev user
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader( req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
`{"text": "test 123", `{"text": "test 123",
"user": {"name": "dev::good@example.com"}, "user": {"name": "provider1_dev::good@example.com"},
"locator":{"url": "https://radio-t.com/blah1", "locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`)) "site": "remark42"}}`))
assert.NoError(t, err) assert.NoError(t, err)
@@ -1023,8 +1288,8 @@ func TestRest_TelegramNotification(t *testing.T) {
body, err = io.ReadAll(resp.Body) body, err = io.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusInternalServerError, resp.StatusCode, string(body)) require.Equal(t, http.StatusNotFound, resp.StatusCode, string(body))
require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"not verified"}`+"\n", string(body)) require.Equal(t, `{"code":0,"details":"request is not verified yet","error":"not verified"}`+"\n", string(body))
mockTlgrm.notVerified = false mockTlgrm.notVerified = false
@@ -1072,7 +1337,7 @@ func TestRest_TelegramNotification(t *testing.T) {
var user store.User var user store.User
err = json.Unmarshal(body, &user) err = json.Unmarshal(body, &user)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, store.User{Name: "developer one", ID: "dev", assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev",
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user) Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user)
// create child comment from another user, telegram notification expected // create child comment from another user, telegram notification expected
@@ -1131,7 +1396,7 @@ func TestRest_UserAllData(t *testing.T) {
defer teardown() defer teardown()
// write 3 comments // write 3 comments
user := store.User{ID: "dev", Name: "user name 1"} user := store.User{ID: "provider1_dev", Name: "user name 1"}
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42", c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)} URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
c2 := store.Comment{User: user, Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42", c2 := store.Comment{User: user, Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
@@ -1156,13 +1421,13 @@ func TestRest_UserAllData(t *testing.T) {
require.Equal(t, "application/gzip", resp.Header.Get("Content-Type")) require.Equal(t, "application/gzip", resp.Header.Get("Content-Type"))
ungzReader, err := gzip.NewReader(resp.Body) ungzReader, err := gzip.NewReader(resp.Body)
assert.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
ungzBody, err := io.ReadAll(ungzReader) ungzBody, err := io.ReadAll(ungzReader)
assert.NoError(t, err) require.NoError(t, err)
strUungzBody := string(ungzBody) strUungzBody := string(ungzBody)
assert.True(t, strings.HasPrefix(strUungzBody, assert.True(t, strings.HasPrefix(strUungzBody,
`{"info": {"name":"developer one","id":"dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`)) `{"info": {"name":"developer one","id":"provider1_dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`))
assert.Equal(t, 3, strings.Count(strUungzBody, `"text":`), "3 comments inside") assert.Equal(t, 3, strings.Count(strUungzBody, `"text":`), "3 comments inside")
parsed := struct { parsed := struct {
@@ -1172,7 +1437,7 @@ func TestRest_UserAllData(t *testing.T) {
err = json.Unmarshal(ungzBody, &parsed) err = json.Unmarshal(ungzBody, &parsed)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, store.User{Name: "developer one", ID: "dev", assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev",
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, parsed.Info) Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, parsed.Info)
assert.Equal(t, 3, len(parsed.Comments)) assert.Equal(t, 3, len(parsed.Comments))
@@ -1188,7 +1453,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
ts, srv, teardown := startupT(t) ts, srv, teardown := startupT(t)
defer teardown() defer teardown()
user := store.User{ID: "dev", Name: "user name 1"} user := store.User{ID: "provider1_dev", Name: "user name 1"}
c := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42", c := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)} URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
@@ -1215,7 +1480,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
strUngzBody := string(ungzBody) strUngzBody := string(ungzBody)
assert.True(t, strings.HasPrefix(strUngzBody, assert.True(t, strings.HasPrefix(strUngzBody,
`{"info": {"name":"developer one","id":"dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`)) `{"info": {"name":"developer one","id":"provider1_dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`))
assert.Equal(t, 51, strings.Count(strUngzBody, `"text":`), "51 comments inside") assert.Equal(t, 51, strings.Count(strUngzBody, `"text":`), "51 comments inside")
} }
@@ -1239,12 +1504,12 @@ func TestRest_DeleteMe(t *testing.T) {
err = json.Unmarshal(body, &m) err = json.Unmarshal(body, &m)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "remark42", m["site"]) assert.Equal(t, "remark42", m["site"])
assert.Equal(t, "dev", m["user_id"]) assert.Equal(t, "provider1_dev", m["user_id"])
tkn := m["token"] tkn := m["token"]
claims, err := srv.Authenticator.TokenService().Parse(tkn) claims, err := srv.Authenticator.TokenService().Parse(tkn)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "dev", claims.User.ID) assert.Equal(t, "provider1_dev", claims.User.ID)
assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+tkn, m["link"]) assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+tkn, m["link"])
req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody) req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody)
+28 -12
View File
@@ -13,7 +13,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
@@ -50,6 +50,8 @@ type pubStore interface {
// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy]&view=[user|all]&since=unix_ts_msec // GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy]&view=[user|all]&since=unix_ts_msec
// find comments for given post. Returns in tree or plain formats, sorted // find comments for given post. Returns in tree or plain formats, sorted
//
// When `url` parameter is not set (e.g. request is for site-wide comments), does not return deleted comments.
func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
sort := r.URL.Query().Get("sort") sort := r.URL.Query().Get("sort")
@@ -77,22 +79,36 @@ func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
comments = []store.Comment{} // error should clear comments and continue for post info comments = []store.Comment{} // error should clear comments and continue for post info
} }
comments = s.applyView(comments, view) comments = s.applyView(comments, view)
var commentsInfo store.PostInfo
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
commentsInfo = info
}
if !since.IsZero() { // if since is set, number of comments can be different from total in the DB
commentsInfo.Count = 0
for _, c := range comments {
if !c.Deleted {
commentsInfo.Count++
}
}
}
// post might be readonly without any comments, Info call will fail then and ReadOnly flag should be checked separately
if !commentsInfo.ReadOnly && locator.URL != "" && s.dataService.IsReadOnly(locator) {
commentsInfo.ReadOnly = true
}
var b []byte var b []byte
switch format { switch format {
case "tree": case "tree":
tree := service.MakeTree(comments, sort, s.readOnlyAge) withInfo := treeWithInfo{Tree: service.MakeTree(comments, sort), Info: commentsInfo}
if tree.Nodes == nil { // eliminate json nil serialization if withInfo.Nodes == nil { // eliminate json nil serialization
tree.Nodes = []*service.Node{} withInfo.Nodes = []*service.Node{}
} }
if s.dataService.IsReadOnly(locator) { b, e = encodeJSONWithHTML(withInfo)
tree.Info.ReadOnly = true
}
b, e = encodeJSONWithHTML(tree)
default: default:
withInfo := commentsWithInfo{Comments: comments} withInfo := commentsWithInfo{Comments: comments, Info: commentsInfo}
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
withInfo.Info = info
}
b, e = encodeJSONWithHTML(withInfo) b, e = encodeJSONWithHTML(withInfo)
} }
return b, e return b, e
+235 -18
View File
@@ -5,12 +5,14 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -76,13 +78,31 @@ func TestRest_Preview(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
assert.Contains(t, assert.Contains(t,
string(b), string(b),
"{\"code\":20,\"details\":\"can't renew staged picture cleanup timer\","+ `{"code":20,"details":"can't load picture from the comment",`+
"\"error\":\"can't get image stats for dev_user/bad_picture: stat", `"error":"can't get image stats for dev_user/bad_picture: stat`,
) )
assert.Contains(t, assert.Contains(t,
string(b), string(b),
"/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n", "/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n",
) )
// test quotes with and without smartypants
resp, err = post(t, ts.URL+"/api/v1/preview", `{"text": "\"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
b, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, "<p>«quoted» text</p>\n", string(b))
srv.privRest.disableFancyTextFormatting = true
resp, err = post(t, ts.URL+"/api/v1/preview", `{"text": "\"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
b, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, "<p>&#34;quoted&#34; text</p>\n", string(b))
} }
func TestRest_PreviewWithWrongImage(t *testing.T) { func TestRest_PreviewWithWrongImage(t *testing.T) {
@@ -97,8 +117,8 @@ func TestRest_PreviewWithWrongImage(t *testing.T) {
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
assert.Contains(t, assert.Contains(t,
string(b), string(b),
"{\"code\":20,\"details\":\"can't renew staged picture cleanup timer\","+ `{"code":20,"details":"can't load picture from the comment",`+
"\"error\":\"can't get image stats for dev_user/bad_picture: stat ", `"error":"can't get image stats for dev_user/bad_picture: stat `,
) )
assert.Contains(t, assert.Contains(t,
string(b), string(b),
@@ -131,10 +151,10 @@ BKT
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, assert.Equal(t,
`<h1>h1</h1> `<h1>h1</h1>
<pre class="chroma"><code><span class="line"><span class="cl">func TestRest_Preview(t *testing.T) { <pre class="chroma"><code><span class="line"><span class="cl"><span class="k">func</span> <span class="n">TestRest_Preview</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">srv, ts := prep(t) </span></span><span class="line"><span class="cl"><span class="n">srv</span><span class="p">,</span> <span class="n">ts</span> <span class="p">:</span><span class="o">=</span> <span class="n">prep</span><span class="p">(</span><span class="n">t</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"> require.NotNil(t, srv) </span></span><span class="line"><span class="cl"> <span class="n">require</span><span class="o">.</span><span class="n">NotNil</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">srv</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">} </span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre>`, </span></span></code></pre>`,
string(b)) string(b))
assert.NoError(t, resp.Body.Close()) assert.NoError(t, resp.Body.Close())
@@ -209,7 +229,7 @@ func TestRest_Find(t *testing.T) {
assert.Equal(t, id2, comments.Comments[0].ID) assert.Equal(t, id2, comments.Comments[0].ID)
// get in tree mode // get in tree mode
tree := service.Tree{} tree := treeWithInfo{}
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
err = json.Unmarshal([]byte(res), &tree) err = json.Unmarshal([]byte(res), &tree)
@@ -235,7 +255,7 @@ func TestRest_FindAge(t *testing.T) {
_, err = srv.DataService.Create(c2) _, err = srv.DataService.Create(c2)
require.NoError(t, err) require.NoError(t, err)
tree := service.Tree{} tree := treeWithInfo{}
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree") res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
@@ -278,7 +298,7 @@ func TestRest_FindReadOnly(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
tree := service.Tree{} tree := treeWithInfo{}
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree") res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
err = json.Unmarshal([]byte(res), &tree) err = json.Unmarshal([]byte(res), &tree)
@@ -286,7 +306,7 @@ func TestRest_FindReadOnly(t *testing.T) {
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL) assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
assert.True(t, tree.Info.ReadOnly, "post is ro") assert.True(t, tree.Info.ReadOnly, "post is ro")
tree = service.Tree{} tree = treeWithInfo{}
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah2&format=tree") res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah2&format=tree")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
err = json.Unmarshal([]byte(res), &tree) err = json.Unmarshal([]byte(res), &tree)
@@ -325,8 +345,8 @@ func TestRest_FindUserView(t *testing.T) {
require.Equal(t, 2, len(comments.Comments), "should have 2 comments") require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, id1, comments.Comments[0].ID) assert.Equal(t, id1, comments.Comments[0].ID)
assert.Equal(t, id2, comments.Comments[1].ID) assert.Equal(t, id2, comments.Comments[1].ID)
assert.Equal(t, "dev", comments.Comments[0].User.ID) assert.Equal(t, "provider1_dev", comments.Comments[0].User.ID)
assert.Equal(t, "dev", comments.Comments[1].User.ID) assert.Equal(t, "provider1_dev", comments.Comments[1].User.ID)
assert.Equal(t, "", comments.Comments[0].Text) assert.Equal(t, "", comments.Comments[0].Text)
assert.Equal(t, "", comments.Comments[1].Text) assert.Equal(t, "", comments.Comments[1].Text)
@@ -440,7 +460,7 @@ func TestRest_FindUserComments(t *testing.T) {
assert.Equal(t, http.StatusOK, code, "noting for user blah") assert.Equal(t, http.StatusOK, code, "noting for user blah")
assert.Equal(t, `{"comments":[],"count":0}`+"\n", comments) assert.Equal(t, `{"comments":[],"count":0}`+"\n", comments)
{ {
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev") res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=provider1_dev")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
resp := struct { resp := struct {
@@ -459,7 +479,7 @@ func TestRest_FindUserComments(t *testing.T) {
} }
{ {
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev&skip=1&limit=2") res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=provider1_dev&skip=1&limit=2")
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, code)
resp := struct { resp := struct {
@@ -477,6 +497,203 @@ func TestRest_FindUserComments(t *testing.T) {
} }
} }
func TestRest_FindUserComments_CWE_918(t *testing.T) {
ts, srv, teardown := startupT(t)
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second}, []string{"radio-t.com"}) // required for extracting the title, bad URL test
defer srv.DataService.TitleExtractor.Close()
defer teardown()
backendRequestedArbitraryServer := false
arbitraryServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
t.Logf("request received: %+v", r)
backendRequestedArbitraryServer = true
}))
defer arbitraryServer.Close()
arbitraryURLComment := store.Comment{Text: "arbitrary URL request test",
Locator: store.Locator{SiteID: "remark42", URL: arbitraryServer.URL}}
assert.False(t, backendRequestedArbitraryServer)
addComment(t, arbitraryURLComment, ts)
assert.False(t, backendRequestedArbitraryServer,
"no request is expected to the test server as it's not in the list of the allowed domains for the title extractor")
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=provider1_dev")
assert.Equal(t, http.StatusOK, code)
resp := struct {
Comments []store.Comment
Count int
}{}
err := json.Unmarshal([]byte(res), &resp)
assert.NoError(t, err)
require.Equal(t, 1, len(resp.Comments), "should have 2 comments")
assert.Equal(t, "", resp.Comments[0].PostTitle, "empty from the first post")
assert.Equal(t, arbitraryServer.URL, resp.Comments[0].Locator.URL, "arbitrary URL provided by the request")
}
func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
// test that comment counting is consistent between tree and plain formats
ts, srv, teardown := startupT(t)
defer teardown()
commentLocator := store.Locator{URL: "test-url", SiteID: "remark42"}
// vote for comment multiple times
setScore := func(locator store.Locator, id string, val int) {
abs := func(x int) int {
if x < 0 {
return -x
}
return x
}
for i := 0; i < abs(val); i++ {
_, err := srv.DataService.Vote(service.VoteReq{
Locator: locator,
CommentID: id,
// unique user ID is needed for correct counting of controversial votes
UserID: "user" + strconv.Itoa(val) + strconv.Itoa(i),
Val: val > 0,
})
require.NoError(t, err)
}
}
// Adding initial comments (8 to test-url and 1 to another-url) and voting, and delete two of comments to the first post.
// With sleep so that at least few millisecond pass between each comment
// and later we would be able to use that in "since" filter with millisecond precision
ids := make([]string, 9)
timestamps := make([]time.Time, 9)
c1 := store.Comment{Text: "top-level comment 1", Locator: commentLocator}
ids[0], timestamps[0] = addCommentGetCreatedTime(t, c1, ts)
// #3 by score
setScore(commentLocator, ids[0], 1)
time.Sleep(time.Millisecond * 5)
c2 := store.Comment{Text: "top-level comment 2", Locator: commentLocator}
ids[1], timestamps[1] = addCommentGetCreatedTime(t, c2, ts)
// #2 by score
setScore(commentLocator, ids[1], 2)
time.Sleep(time.Millisecond * 5)
c3 := store.Comment{Text: "second-level comment 1", ParentID: ids[0], Locator: commentLocator}
ids[2], timestamps[2] = addCommentGetCreatedTime(t, c3, ts)
// #1 by score
setScore(commentLocator, ids[2], 10)
time.Sleep(time.Millisecond * 5)
c4 := store.Comment{Text: "third-level comment 1", ParentID: ids[2], Locator: commentLocator}
ids[3], timestamps[3] = addCommentGetCreatedTime(t, c4, ts)
// #5 by score, #1 by controversy
setScore(commentLocator, ids[3], 4)
setScore(commentLocator, ids[3], -4)
time.Sleep(time.Millisecond * 5)
c5 := store.Comment{Text: "second-level comment 2", ParentID: ids[1], Locator: commentLocator}
ids[4], timestamps[4] = addCommentGetCreatedTime(t, c5, ts)
// #5 by score, #2 by controversy
setScore(commentLocator, ids[4], 2)
setScore(commentLocator, ids[4], -3)
time.Sleep(time.Millisecond * 5)
c6 := store.Comment{Text: "third-level comment 2", ParentID: ids[4], Locator: commentLocator}
ids[5], timestamps[5] = addCommentGetCreatedTime(t, c6, ts)
// deleted later so not visible in site-wide requests
setScore(commentLocator, ids[5], 10)
setScore(commentLocator, ids[5], -10)
time.Sleep(time.Millisecond * 5)
c7 := store.Comment{Text: "top-level comment 3", Locator: commentLocator}
ids[6], timestamps[6] = addCommentGetCreatedTime(t, c7, ts)
// #6 by score, #4 by controversy
setScore(commentLocator, ids[6], -3)
setScore(commentLocator, ids[6], 1)
time.Sleep(time.Millisecond * 5)
c8 := store.Comment{Text: "second-level comment 3", ParentID: ids[6], Locator: commentLocator}
ids[7], timestamps[7] = addCommentGetCreatedTime(t, c8, ts)
// deleted later so not visible in site-wide requests
setScore(commentLocator, ids[7], -20)
c9 := store.Comment{Text: "comment to post 2", Locator: store.Locator{URL: "another-url", SiteID: "remark42"}}
ids[8], timestamps[8] = addCommentGetCreatedTime(t, c9, ts)
// #7 by score
setScore(store.Locator{URL: "another-url", SiteID: "remark42"}, ids[8], -25)
// delete two comments bringing the total from 9 to 6
err := srv.DataService.Delete(commentLocator, ids[7], store.SoftDelete)
assert.NoError(t, err)
err = srv.DataService.Delete(commentLocator, ids[5], store.HardDelete)
assert.NoError(t, err)
srv.Cache.Flush(cache.FlusherRequest{})
commentLocator.URL = "readonly-test"
// set post without comments to read-only
assert.NoError(t, srv.DataService.SetReadOnly(commentLocator, true))
sinceTenSecondsAgo := strconv.FormatInt(time.Now().Add(-time.Second*10).UnixNano()/1000000, 10)
sinceTS := make([]string, 9)
formattedTS := make([]string, 9)
for i, created := range timestamps {
sinceTS[i] = strconv.FormatInt(created.UnixNano()/1000000, 10)
formattedTS[i] = created.Format(time.RFC3339Nano)
}
t.Logf("last timestamp: %v", timestamps[7])
testCases := []struct {
params string
expectedBody string
}{
{"", fmt.Sprintf(`"info":{"count":7,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
{"url=test-url", fmt.Sprintf(`"info":{"url":"test-url","count":6,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
{"format=plain", fmt.Sprintf(`"info":{"count":7,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
{"format=plain&url=test-url", fmt.Sprintf(`"info":{"url":"test-url","count":6,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
{"since=" + sinceTenSecondsAgo, fmt.Sprintf(`"info":{"count":7,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
{"url=test-url&since=" + sinceTenSecondsAgo, fmt.Sprintf(`"info":{"url":"test-url","count":6,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
{"since=" + sinceTS[0], fmt.Sprintf(`"info":{"count":7,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
{"url=test-url&since=" + sinceTS[0], fmt.Sprintf(`"info":{"url":"test-url","count":6,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
{"since=" + sinceTS[1], fmt.Sprintf(`"info":{"count":6,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
{"url=test-url&since=" + sinceTS[1], fmt.Sprintf(`"info":{"url":"test-url","count":5,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
{"since=" + sinceTS[4], fmt.Sprintf(`"info":{"count":3,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
{"url=test-url&since=" + sinceTS[4], fmt.Sprintf(`"info":{"url":"test-url","count":2,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
{"format=tree", `"info":{"count":7`},
{"format=tree&url=test-url", `"info":{"url":"test-url","count":6`},
{"format=tree&sort=+time", `"info":{"count":7`},
{"format=tree&url=test-url&sort=+time", `"info":{"url":"test-url","count":6`},
{"format=tree&sort=-score", `"info":{"count":7`},
{"format=tree&url=test-url&sort=-score", `"info":{"url":"test-url","count":6`},
{"sort=+time", fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[8])},
{"sort=-time", fmt.Sprintf(`"score":1,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[0])},
{"sort=+score", fmt.Sprintf(`"score":10,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[2])},
{"sort=+score&url=test-url", fmt.Sprintf(`"score":10,"vote":0,"time":%q}],"info":{"url":"test-url","count":6`, formattedTS[2])},
{"sort=-score", fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[8])},
{"sort=-score&url=test-url", fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":6`, formattedTS[6])},
{"sort=-time&since=" + sinceTS[4], fmt.Sprintf(`"score":-1,"vote":0,"controversy":2.924017738212866,"time":%q}],"info":{"count":3`, formattedTS[4])},
{"sort=-score&since=" + sinceTS[3], fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":4`, formattedTS[8])},
{"sort=-score&url=test-url&since=" + sinceTS[3], fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":3`, formattedTS[6])},
{"sort=+controversy&url=test-url&since=" + sinceTS[5], fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":1`, formattedTS[6])},
// three comments of which last one deleted and doesn't have controversy so returned last
{"sort=-controversy&url=test-url&since=" + sinceTS[5], fmt.Sprintf(`"score":0,"vote":0,"time":%q,"delete":true}],"info":{"url":"test-url","count":1`, formattedTS[7])},
// test readonly status for the post without comments
{"url=readonly-test", `"info":{"count":0,"read_only":true`},
{"format=tree&url=readonly-test", `"info":{"count":0,"read_only":true`},
}
for _, tc := range testCases {
t.Run(tc.params, func(t *testing.T) {
url := fmt.Sprintf(ts.URL+"/api/v1/find?site=remark42&%s", tc.params)
body, code := get(t, url)
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, tc.expectedBody)
t.Log(body)
// prevent hit limiter from engaging
time.Sleep(50 * time.Millisecond)
})
}
}
func TestRest_UserInfo(t *testing.T) { func TestRest_UserInfo(t *testing.T) {
ts, _, teardown := startupT(t) ts, _, teardown := startupT(t)
defer teardown() defer teardown()
@@ -486,7 +703,7 @@ func TestRest_UserInfo(t *testing.T) {
user := store.User{} user := store.User{}
err := json.Unmarshal([]byte(body), &user) err := json.Unmarshal([]byte(body), &user)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "http://example.com/pic.png", assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev", Picture: "http://example.com/pic.png",
IP: "127.0.0.1", SiteID: "remark42"}, user) IP: "127.0.0.1", SiteID: "remark42"}, user)
} }
+108 -41
View File
@@ -18,8 +18,9 @@ import (
"github.com/go-pkgz/auth" "github.com/go-pkgz/auth"
"github.com/go-pkgz/auth/avatar" "github.com/go-pkgz/auth/avatar"
"github.com/go-pkgz/auth/provider"
"github.com/go-pkgz/auth/token" "github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
R "github.com/go-pkgz/rest" R "github.com/go-pkgz/rest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -37,11 +38,18 @@ import (
"github.com/umputun/remark42/backend/app/store/service" "github.com/umputun/remark42/backend/app/store/service"
) )
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg` // To generate a token, enter one of the tokens here into https://jwt.io, change the secret to one you're using in your test
// ("secret" in case of startupT), and alter the fields you want to be changed.
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6InByb3ZpZGVyMV9kZXYiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoibWVAZXhhbXBsZS5jb20ifX0.dirTS_ahSF6375sdO2iodm2K2UmRTzQNQMFiHuTQCVs`
var dev2Token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6InByb3ZpZGVyMV9kZXYyIiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Im1lQGV4YW1wbGUuY29tIn19.qsR_PupfjIq7uw0eAuyGV8nsUoMx9v541c9olnRInRQ`
var anonToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImFub255bW91cyB0ZXN0IHVzZXIiLCJpZCI6ImFub255bW91c190ZXN0X3VzZXIiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoiYW5vbkBleGFtcGxlLmNvbSJ9fQ.gAae2WMxZNZE5ebVboptPEyQ7Nk6EQxciNnGJ_mPOuU` var anonToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImFub255bW91cyB0ZXN0IHVzZXIiLCJpZCI6ImFub255bW91c190ZXN0X3VzZXIiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoiYW5vbkBleGFtcGxlLmNvbSJ9fQ.gAae2WMxZNZE5ebVboptPEyQ7Nk6EQxciNnGJ_mPOuU`
var devTokenBadAud = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0Ml9iYWQiLCJleHAiOjM3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTIxODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJkZXYiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoibWVAZXhhbXBsZS5jb20ifX0.FuTTocVtcxr4VjpfIICvU2yOb3su28VkDzj94H9Q3xY` var emailUserToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6Imdvb2RAZXhhbXBsZS5jb20gdGVzdCB1c2VyIiwiaWQiOiJlbWFpbF9mNWRmZTlkMmU2YmQ3NWZjNzRlYTVmYWJmMjczYjQ1YjViYWViMTk1IiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Imdvb2RAZXhhbXBsZS5jb20ifX0.vH2HN1JpuXL8okTJq1A-zGHQ-l2ILcwxvDDEmu2zwks`
var devTokenBadAud = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0Ml9iYWQiLCJleHAiOjM3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTIxODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJwcm92aWRlcjFfZGV2IiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Im1lQGV4YW1wbGUuY29tIn19.X-lvnHvBz6VfEbVV4f-bjcZuLY5pYtvEansk_TQMrX8`
var adminUmputunToken = `eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6MTk1NDU5Nzk4MCwianRpIjoiOTdhMmUwYWM0ZGM3ZDVmNjkyNmQ1ZTg2MjBhY2VmOWE0MGMwIiwiaWF0IjoxNDU0NTk3NjgwLCJpc3MiOiJyZW1hcms0MiIsInVzZXIiOnsibmFtZSI6IlVtcHV0dW4iLCJpZCI6ImdpdGh1Yl9lZjBmNzA2YTciLCJwaWN0dXJlIjoiaHR0cHM6Ly9yZW1hcms0Mi5yYWRpby10LmNvbS9hcGkvdjEvYXZhdGFyL2NiNDJmZjQ5M2FkZTY5NmQ4OGEzYTU5MGYxMzZhZTllMzRkZTdjMWIuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.dZiOjWHguo9f42XCMooMcv4EmYFzifl_-LEvPZHCtks` var adminUmputunToken = `eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6MTk1NDU5Nzk4MCwianRpIjoiOTdhMmUwYWM0ZGM3ZDVmNjkyNmQ1ZTg2MjBhY2VmOWE0MGMwIiwiaWF0IjoxNDU0NTk3NjgwLCJpc3MiOiJyZW1hcms0MiIsInVzZXIiOnsibmFtZSI6IlVtcHV0dW4iLCJpZCI6ImdpdGh1Yl9lZjBmNzA2YTciLCJwaWN0dXJlIjoiaHR0cHM6Ly9yZW1hcms0Mi5yYWRpby10LmNvbS9hcGkvdjEvYXZhdGFyL2NiNDJmZjQ5M2FkZTY5NmQ4OGEzYTU5MGYxMzZhZTllMzRkZTdjMWIuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.dZiOjWHguo9f42XCMooMcv4EmYFzifl_-LEvPZHCtks`
@@ -120,7 +128,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
@@ -170,7 +178,7 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
} }
@@ -186,7 +194,7 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
} }
func TestRest_rejectAnonUser(t *testing.T) { func TestRest_rejectAnonUser(t *testing.T) {
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "Hello") fmt.Fprintln(w, "Hello")
})))) }))))
defer ts.Close() defer ts.Close()
@@ -296,10 +304,10 @@ func TestRest_cacheControl(t *testing.T) {
for i, tt := range tbl { for i, tt := range tbl {
tt := tt tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) { t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, nil) req := httptest.NewRequest("GET", tt.url, http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req) h.ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -312,30 +320,54 @@ func TestRest_cacheControl(t *testing.T) {
} }
func TestRest_frameAncestors(t *testing.T) { func TestRest_frameAncestors(t *testing.T) {
tbl := []struct { ts, _, teardown := startupT(t, func(o *Rest) {
hosts []string o.AllowedAncestors = []string{"'self'", "https://example.com"}
header string })
}{
{[]string{"http://example.com"}, "frame-ancestors http://example.com;"},
{[]string{}, ""},
{[]string{"http://example.com", "http://example2.com"}, "frame-ancestors http://example.com http://example2.com;"},
}
for i, tt := range tbl { // Test case with frame-ancestors
tt := tt client := http.Client{}
t.Run(strconv.Itoa(i), func(t *testing.T) { resp, err := client.Get(ts.URL + "/web/index.html")
req := httptest.NewRequest("GET", "http://example.com", nil) require.NoError(t, err)
w := httptest.NewRecorder() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
teardown()
h := frameAncestors(tt.hosts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) // Test case without frame-ancestors
h.ServeHTTP(w, req) ts, _, teardown = startupT(t, func(srv *Rest) {
resp := w.Result() srv.AllowedAncestors = []string{}
assert.Equal(t, http.StatusOK, resp.StatusCode) })
assert.NoError(t, resp.Body.Close()) defer teardown()
t.Logf("%+v", resp.Header) resp, err = client.Get(ts.URL + "/web/index.html")
assert.Equal(t, tt.header, resp.Header.Get("Content-Security-Policy")) require.NoError(t, err)
}) defer resp.Body.Close()
} assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors *;")
}
// 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 *;")
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';")
} }
func TestRest_subscribersOnly(t *testing.T) { func TestRest_subscribersOnly(t *testing.T) {
@@ -358,12 +390,12 @@ func TestRest_subscribersOnly(t *testing.T) {
for i, tt := range tbl { for i, tt := range tbl {
tt := tt tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) { t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil) req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
if tt.setUser { if tt.setUser {
req = token.SetUserInfo(req, tt.user) req = token.SetUserInfo(req, tt.user)
} }
w := httptest.NewRecorder() w := httptest.NewRecorder()
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req) h.ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
assert.Equal(t, tt.status, resp.StatusCode) assert.Equal(t, tt.status, resp.StatusCode)
@@ -395,7 +427,7 @@ func Test_validEmailAuth(t *testing.T) {
t.Run(strconv.Itoa(i), func(t *testing.T) { t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody) req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
h := validEmailAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(w, req) h.ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
assert.Equal(t, tt.status, resp.StatusCode) assert.Equal(t, tt.status, resp.StatusCode)
@@ -430,7 +462,7 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDB, SiteID: "remark42"}) b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDB, SiteID: "remark42"})
require.NoError(t, err) require.NoError(t, err)
memCache := cache.NewScache(cache.NewNopCache()) memCache := cache.NewScache[[]byte](cache.NewNopCache[[]byte]())
astore := adminstore.NewStaticStore("123456", []string{"remark42"}, []string{"a1", "a2"}, "admin@remark-42.com") astore := adminstore.NewStaticStore("123456", []string{"remark42"}, []string{"a1", "a2"}, "admin@remark-42.com")
restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}}) restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}})
@@ -450,7 +482,7 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
DataService: dataStore, DataService: dataStore,
Authenticator: auth.NewService(auth.Opts{ Authenticator: auth.NewService(auth.Opts{
AdminPasswd: "password", AdminPasswd: "password",
SecretReader: token.SecretFunc(func(aud string) (string, error) { return "secret", nil }), SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"), AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
}), }),
Cache: memCache, Cache: memCache,
@@ -479,16 +511,26 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
Cache: memCache, Cache: memCache,
KeyStore: astore, KeyStore: astore,
}, },
NotifyService: notify.NopService, NotifyService: notify.NopService,
EmojiEnabled: true, EmojiEnabled: true,
openRouteLimiter: 100,
} }
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10 srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
// add some providers. Needed because we don't allow users with unlisted providers to authenticate
providers := []string{"provider1", "anonymous", "github", "email"}
for _, p := range providers {
srv.Authenticator.AddDirectProvider(p, provider.CredCheckerFunc(func(_, _ string) (ok bool, err error) {
return true, nil
}))
}
for _, h := range srvHook { for _, h := range srvHook {
h(srv) h(srv)
} }
ts = httptest.NewServer(srv.routes()) routes := srv.routes()
ts = httptest.NewServer(routes)
teardown = func() { teardown = func() {
ts.Close() ts.Close()
@@ -547,6 +589,20 @@ func getWithDevAuth(t *testing.T, url string) (body string, code int) {
return string(b), r.StatusCode return string(b), r.StatusCode
} }
func getWithDev2Auth(t *testing.T, url string) (body string, code int) {
client := &http.Client{Timeout: 5 * time.Second}
defer client.CloseIdleConnections()
req, err := http.NewRequest("GET", url, http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", dev2Token)
r, err := client.Do(req)
require.NoError(t, err)
b, err := io.ReadAll(r.Body)
assert.NoError(t, err)
require.NoError(t, r.Body.Close())
return string(b), r.StatusCode
}
func getWithAdminAuth(t *testing.T, url string) (response string, statusCode int) { func getWithAdminAuth(t *testing.T, url string) (response string, statusCode int) {
client := &http.Client{Timeout: 5 * time.Second} client := &http.Client{Timeout: 5 * time.Second}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
@@ -569,7 +625,7 @@ func post(t *testing.T, url, body string) (*http.Response, error) {
return client.Do(req) return client.Do(req)
} }
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string { func addCommentGetCreatedTime(t *testing.T, c store.Comment, ts *httptest.Server) (id string, created time.Time) {
b, err := json.Marshal(c) b, err := json.Marshal(c)
require.NoError(t, err, "can't marshal comment %+v", c) require.NoError(t, err, "can't marshal comment %+v", c)
@@ -589,7 +645,14 @@ func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
err = json.Unmarshal(b, &crResp) err = json.Unmarshal(b, &crResp)
require.NoError(t, err) require.NoError(t, err)
time.Sleep(time.Nanosecond * 10) time.Sleep(time.Nanosecond * 10)
return crResp["id"].(string) created, err = time.Parse(time.RFC3339, crResp["time"].(string))
require.NoError(t, err)
return crResp["id"].(string), created
}
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
id, _ := addCommentGetCreatedTime(t, c, ts)
return id
} }
func requireAdminOnly(t *testing.T, req *http.Request) { func requireAdminOnly(t *testing.T, req *http.Request) {
@@ -628,5 +691,9 @@ func waitForHTTPSServerStart(port int) {
} }
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) goleak.VerifyTestMain(
m,
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
)
} }
+1 -2
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
cache "github.com/go-pkgz/lcw" cache "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"github.com/gorilla/feeds" "github.com/gorilla/feeds"
@@ -56,7 +56,6 @@ func (s *rss) postCommentsCtrl(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if _, err = w.Write(data); err != nil { if _, err = w.Write(data); err != nil {
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err) log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
} }
+2 -2
View File
@@ -21,7 +21,7 @@ func TestSSL_Redirect(t *testing.T) {
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
@@ -56,7 +56,7 @@ func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
client := http.Client{ client := http.Client{
// prevent http redirect // prevent http redirect
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
} }
+116
View File
@@ -0,0 +1,116 @@
{
"version": 1,
"comments": [
{
"commentHex": "e7a2ef4b4aa1414a7ee65a989889aaecd9d5e7e3bca598ea7a967b4dbcaa8e11",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2022-10-25T07:25:46.807555Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "a29e741145daceb4ca5b3e5e279e05b56f73c04703d93b944718ef757e15317f",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-07-26T12:24:55.058552Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-10-31T11:03:25.403282Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "6d3bb64ff73b5f9d6a959212ffde472a51abf8bdefaa5ed843659796bceef9de",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
"score": 0,
"state": "approved",
"creationDate": "2023-11-01T22:23:47.112062Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-10-23T12:33:03.370182Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "d0ad6f11cf0c5f8e17457a378a6bb789f412c6b7ef7ada4ae06ec8451f7a18aa",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-10-18T01:18:38.193625Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "098960fd01c1fc7c0d3ea428f52fab97ea5c18aa52f3565bba679224daddc687",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
"score": 0,
"state": "approved",
"creationDate": "2023-11-01T22:24:04.639965Z",
"direction": 0,
"deleted": false
}
],
"commenters": [
{
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"email": "undefined",
"name": "blank",
"link": "undefined",
"photo": "undefined",
"provider": "anon",
"joinDate": "2022-06-09T15:54:29.865919Z",
"isModerator": false,
"deleted": false
}
]
}
+11 -2
View File
@@ -57,7 +57,7 @@ func (p Image) extract(commentHTML string, imgSrcPred func(string) bool) ([]stri
return nil, fmt.Errorf("can't create document: %w", err) return nil, fmt.Errorf("can't create document: %w", err)
} }
result := []string{} result := []string{}
doc.Find("img").Each(func(i int, s *goquery.Selection) { doc.Find("img").Each(func(_ int, s *goquery.Selection) {
if im, ok := s.Attr("src"); ok { if im, ok := s.Attr("src"); ok {
if imgSrcPred(im) { if imgSrcPred(im) {
result = append(result, im) result = append(result, im)
@@ -98,6 +98,10 @@ func (p Image) Handler(w http.ResponseWriter, r *http.Request) {
if img == nil { if img == nil {
img, err = p.downloadImage(context.Background(), imgURL) img, err = p.downloadImage(context.Background(), imgURL)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "invalid content type") {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid content type", rest.ErrImgNotFound)
return
}
rest.SendErrorJSON(w, r, http.StatusNotFound, err, "can't get image "+imgURL, rest.ErrAssetNotFound) rest.SendErrorJSON(w, r, http.StatusNotFound, err, "can't get image "+imgURL, rest.ErrAssetNotFound)
return return
} }
@@ -159,12 +163,17 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error)
if err != nil { if err != nil {
return nil, fmt.Errorf("can't download image %s: %w", imgURL, err) return nil, fmt.Errorf("can't download image %s: %w", imgURL, err)
} }
defer resp.Body.Close() defer resp.Body.Close() //nolint gosec // we don't care about response body
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("got unsuccessful response status %d while fetching %s", resp.StatusCode, imgURL) return nil, fmt.Errorf("got unsuccessful response status %d while fetching %s", resp.StatusCode, imgURL)
} }
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return nil, fmt.Errorf("invalid content type %s", contentType)
}
imgData, err := io.ReadAll(resp.Body) imgData, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to read image body") return nil, fmt.Errorf("unable to read image body")
+38 -24
View File
@@ -95,7 +95,7 @@ func TestImage_Replace(t *testing.T) {
func TestImage_Routes(t *testing.T) { func TestImage_Routes(t *testing.T) {
// no image supposed to be cached // no image supposed to be cached
imageStore := image.StoreMock{LoadFunc: func(id string) ([]byte, error) { return nil, nil }} imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
img := Image{ img := Image{
HTTP2HTTPS: true, HTTP2HTTPS: true,
RemarkURL: "https://demo.remark42.com", RemarkURL: "https://demo.remark42.com",
@@ -108,31 +108,45 @@ func TestImage_Routes(t *testing.T) {
httpSrv := imgHTTPTestsServer(t) httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close() defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png")) t.Run("valid image", func(t *testing.T) {
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "1462", resp.Header["Content-Length"][0])
assert.Equal(t, "image/png", resp.Header["Content-Type"][0])
})
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) t.Run("no image", func(t *testing.T) {
require.NoError(t, err) encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/no-such-image.png"))
assert.NoError(t, resp.Body.Close()) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
assert.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, err)
assert.Equal(t, "1462", resp.Header["Content-Length"][0]) assert.NoError(t, resp.Body.Close())
assert.Equal(t, "image/png", resp.Header["Content-Type"][0]) assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
encodedImgURL = base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/no-such-image.png")) t.Run("bad encoding", func(t *testing.T) {
resp, err = http.Get(ts.URL + "/?src=" + encodedImgURL) encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "bad encoding"))
require.NoError(t, err) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
assert.NoError(t, resp.Body.Close()) require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode) assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, 2, len(imageStore.LoadCalls()))
})
encodedImgURL = base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "bad encoding")) t.Run("non-image reference", func(t *testing.T) {
resp, err = http.Get(ts.URL + "/?src=" + encodedImgURL) encodedImgURL := base64.URLEncoding.EncodeToString([]byte("https://google.com"))
require.NoError(t, err) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
assert.NoError(t, resp.Body.Close()) require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode) assert.NoError(t, resp.Body.Close())
assert.Equal(t, 2, len(imageStore.LoadCalls())) assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, 3, len(imageStore.LoadCalls()))
})
} }
func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) { func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) {
imageStore := image.StoreMock{LoadFunc: func(id string) ([]byte, error) { return nil, nil }} imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
img := Image{ img := Image{
RemarkURL: "https://demo.remark42.com", RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy", RoutePath: "/api/v1/proxy",
@@ -158,10 +172,10 @@ func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) {
func TestImage_RoutesCachingImage(t *testing.T) { func TestImage_RoutesCachingImage(t *testing.T) {
imageStore := image.StoreMock{ imageStore := image.StoreMock{
LoadFunc: func(id string) ([]byte, error) { LoadFunc: func(string) ([]byte, error) {
return nil, nil return nil, nil
}, },
SaveFunc: func(id string, img []byte) error { SaveFunc: func(string, []byte) error {
return nil return nil
}, },
} }
@@ -196,7 +210,7 @@ func TestImage_RoutesCachingImage(t *testing.T) {
func TestImage_RoutesUsingCachedImage(t *testing.T) { func TestImage_RoutesUsingCachedImage(t *testing.T) {
// In order to validate that cached data used cache "will return" some other data from what http server would // In order to validate that cached data used cache "will return" some other data from what http server would
testImage := []byte(fmt.Sprintf("%256s", "X")) testImage := []byte(fmt.Sprintf("%256s", "X"))
imageStore := image.StoreMock{LoadFunc: func(id string) ([]byte, error) { imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
return testImage, nil return testImage, nil
}} }}
img := Image{ img := Image{
@@ -226,7 +240,7 @@ func TestImage_RoutesUsingCachedImage(t *testing.T) {
func TestImage_RoutesTimedOut(t *testing.T) { func TestImage_RoutesTimedOut(t *testing.T) {
// no image supposed to be cached // no image supposed to be cached
imageStore := image.StoreMock{LoadFunc: func(id string) ([]byte, error) { return nil, nil }} imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
img := Image{ img := Image{
HTTP2HTTPS: true, HTTP2HTTPS: true,
RemarkURL: "https://demo.remark42.com", RemarkURL: "https://demo.remark42.com",
+3
View File
@@ -8,6 +8,9 @@ import (
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
) )
// NOTE: matryer/moq should be installed globally and works with `go generate ./...`
//go:generate moq --out admin_mock.go . Store
// Store defines interface returning admins info for given site // Store defines interface returning admins info for given site
type Store interface { type Store interface {
Key(siteID string) (key string, err error) Key(siteID string) (key string, err error)
+256
View File
@@ -0,0 +1,256 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package admin
import (
"sync"
)
// Ensure, that StoreMock does implement Store.
// If this is not the case, regenerate this file with moq.
var _ Store = &StoreMock{}
// StoreMock is a mock implementation of Store.
//
// func TestSomethingThatUsesStore(t *testing.T) {
//
// // make and configure a mocked Store
// mockedStore := &StoreMock{
// AdminsFunc: func(siteID string) ([]string, error) {
// panic("mock out the Admins method")
// },
// EmailFunc: func(siteID string) (string, error) {
// panic("mock out the Email method")
// },
// EnabledFunc: func(siteID string) (bool, error) {
// panic("mock out the Enabled method")
// },
// KeyFunc: func(siteID string) (string, error) {
// panic("mock out the Key method")
// },
// OnEventFunc: func(siteID string, et EventType) error {
// panic("mock out the OnEvent method")
// },
// }
//
// // use mockedStore in code that requires Store
// // and then make assertions.
//
// }
type StoreMock struct {
// AdminsFunc mocks the Admins method.
AdminsFunc func(siteID string) ([]string, error)
// EmailFunc mocks the Email method.
EmailFunc func(siteID string) (string, error)
// EnabledFunc mocks the Enabled method.
EnabledFunc func(siteID string) (bool, error)
// KeyFunc mocks the Key method.
KeyFunc func(siteID string) (string, error)
// OnEventFunc mocks the OnEvent method.
OnEventFunc func(siteID string, et EventType) error
// calls tracks calls to the methods.
calls struct {
// Admins holds details about calls to the Admins method.
Admins []struct {
// SiteID is the siteID argument value.
SiteID string
}
// Email holds details about calls to the Email method.
Email []struct {
// SiteID is the siteID argument value.
SiteID string
}
// Enabled holds details about calls to the Enabled method.
Enabled []struct {
// SiteID is the siteID argument value.
SiteID string
}
// Key holds details about calls to the Key method.
Key []struct {
// SiteID is the siteID argument value.
SiteID string
}
// OnEvent holds details about calls to the OnEvent method.
OnEvent []struct {
// SiteID is the siteID argument value.
SiteID string
// Et is the et argument value.
Et EventType
}
}
lockAdmins sync.RWMutex
lockEmail sync.RWMutex
lockEnabled sync.RWMutex
lockKey sync.RWMutex
lockOnEvent sync.RWMutex
}
// Admins calls AdminsFunc.
func (mock *StoreMock) Admins(siteID string) ([]string, error) {
if mock.AdminsFunc == nil {
panic("StoreMock.AdminsFunc: method is nil but Store.Admins was just called")
}
callInfo := struct {
SiteID string
}{
SiteID: siteID,
}
mock.lockAdmins.Lock()
mock.calls.Admins = append(mock.calls.Admins, callInfo)
mock.lockAdmins.Unlock()
return mock.AdminsFunc(siteID)
}
// AdminsCalls gets all the calls that were made to Admins.
// Check the length with:
//
// len(mockedStore.AdminsCalls())
func (mock *StoreMock) AdminsCalls() []struct {
SiteID string
} {
var calls []struct {
SiteID string
}
mock.lockAdmins.RLock()
calls = mock.calls.Admins
mock.lockAdmins.RUnlock()
return calls
}
// Email calls EmailFunc.
func (mock *StoreMock) Email(siteID string) (string, error) {
if mock.EmailFunc == nil {
panic("StoreMock.EmailFunc: method is nil but Store.Email was just called")
}
callInfo := struct {
SiteID string
}{
SiteID: siteID,
}
mock.lockEmail.Lock()
mock.calls.Email = append(mock.calls.Email, callInfo)
mock.lockEmail.Unlock()
return mock.EmailFunc(siteID)
}
// EmailCalls gets all the calls that were made to Email.
// Check the length with:
//
// len(mockedStore.EmailCalls())
func (mock *StoreMock) EmailCalls() []struct {
SiteID string
} {
var calls []struct {
SiteID string
}
mock.lockEmail.RLock()
calls = mock.calls.Email
mock.lockEmail.RUnlock()
return calls
}
// Enabled calls EnabledFunc.
func (mock *StoreMock) Enabled(siteID string) (bool, error) {
if mock.EnabledFunc == nil {
panic("StoreMock.EnabledFunc: method is nil but Store.Enabled was just called")
}
callInfo := struct {
SiteID string
}{
SiteID: siteID,
}
mock.lockEnabled.Lock()
mock.calls.Enabled = append(mock.calls.Enabled, callInfo)
mock.lockEnabled.Unlock()
return mock.EnabledFunc(siteID)
}
// EnabledCalls gets all the calls that were made to Enabled.
// Check the length with:
//
// len(mockedStore.EnabledCalls())
func (mock *StoreMock) EnabledCalls() []struct {
SiteID string
} {
var calls []struct {
SiteID string
}
mock.lockEnabled.RLock()
calls = mock.calls.Enabled
mock.lockEnabled.RUnlock()
return calls
}
// Key calls KeyFunc.
func (mock *StoreMock) Key(siteID string) (string, error) {
if mock.KeyFunc == nil {
panic("StoreMock.KeyFunc: method is nil but Store.Key was just called")
}
callInfo := struct {
SiteID string
}{
SiteID: siteID,
}
mock.lockKey.Lock()
mock.calls.Key = append(mock.calls.Key, callInfo)
mock.lockKey.Unlock()
return mock.KeyFunc(siteID)
}
// KeyCalls gets all the calls that were made to Key.
// Check the length with:
//
// len(mockedStore.KeyCalls())
func (mock *StoreMock) KeyCalls() []struct {
SiteID string
} {
var calls []struct {
SiteID string
}
mock.lockKey.RLock()
calls = mock.calls.Key
mock.lockKey.RUnlock()
return calls
}
// OnEvent calls OnEventFunc.
func (mock *StoreMock) OnEvent(siteID string, et EventType) error {
if mock.OnEventFunc == nil {
panic("StoreMock.OnEventFunc: method is nil but Store.OnEvent was just called")
}
callInfo := struct {
SiteID string
Et EventType
}{
SiteID: siteID,
Et: et,
}
mock.lockOnEvent.Lock()
mock.calls.OnEvent = append(mock.calls.OnEvent, callInfo)
mock.lockOnEvent.Unlock()
return mock.OnEventFunc(siteID, et)
}
// OnEventCalls gets all the calls that were made to OnEvent.
// Check the length with:
//
// len(mockedStore.OnEventCalls())
func (mock *StoreMock) OnEventCalls() []struct {
SiteID string
Et EventType
} {
var calls []struct {
SiteID string
Et EventType
}
mock.lockOnEvent.RLock()
calls = mock.calls.OnEvent
mock.lockOnEvent.RUnlock()
return calls
}
+14
View File
@@ -6,6 +6,20 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestStaticStore_StoreWithoutSites(t *testing.T) {
var ks Store = NewStaticKeyStore("key123")
enabled, err := ks.Enabled("any")
assert.NoError(t, err)
assert.True(t, enabled, "on empty store all sites are enabled")
assert.NoError(t, ks.OnEvent("test", EvCreate), "static store does nothing OnEvent")
// empty key
ks = NewStaticKeyStore("")
key, err := ks.Key("any")
assert.Error(t, err, "empty key")
assert.Empty(t, key)
}
func TestStaticStore_Get(t *testing.T) { func TestStaticStore_Get(t *testing.T) {
var ks Store = NewStaticStore("key123", []string{"s1", "s2", "s3"}, var ks Store = NewStaticStore("key123", []string{"s1", "s2", "s3"},
[]string{"123", "xyz"}, "aa@example.com") []string{"123", "xyz"}, "aa@example.com")
+15 -8
View File
@@ -45,9 +45,9 @@ type Edit struct {
// PostInfo holds summary for given post url // PostInfo holds summary for given post url
type PostInfo struct { type PostInfo struct {
URL string `json:"url"` URL string `json:"url,omitempty"` // can be attached to site-wide comments but won't be set then
Count int `json:"count"` Count int `json:"count"`
ReadOnly bool `json:"read_only,omitempty" bson:"read_only,omitempty"` ReadOnly bool `json:"read_only,omitempty" bson:"read_only,omitempty"` // can be attached to site-wide comments but won't be set then
FirstTS time.Time `json:"first_time,omitempty" bson:"first_time,omitempty"` FirstTS time.Time `json:"first_time,omitempty" bson:"first_time,omitempty"`
LastTS time.Time `json:"last_time,omitempty" bson:"last_time,omitempty"` LastTS time.Time `json:"last_time,omitempty" bson:"last_time,omitempty"`
} }
@@ -98,6 +98,7 @@ func (c *Comment) SetDeleted(mode DeleteMode) {
c.Text = "" c.Text = ""
c.Orig = "" c.Orig = ""
c.Score = 0 c.Score = 0
c.Controversy = 0
c.Votes = map[string]bool{} c.Votes = map[string]bool{}
c.VotedIPs = make(map[string]VotedIPInfo) c.VotedIPs = make(map[string]VotedIPInfo)
c.Edit = nil c.Edit = nil
@@ -118,9 +119,11 @@ func (c *Comment) SetDeleted(mode DeleteMode) {
func (c *Comment) Sanitize() { func (c *Comment) Sanitize() {
p := bluemonday.UGCPolicy() p := bluemonday.UGCPolicy()
p.AllowAttrs("class").Matching(regexp.MustCompile("^chroma$")).OnElements("pre") p.AllowAttrs("class").Matching(regexp.MustCompile("^chroma$")).OnElements("pre")
// special case for embedding the quotes from Twitter
p.AllowAttrs("class").Matching(regexp.MustCompile("^twitter-tweet$")).OnElements("blockquote")
// this is list of <span> tag classes which could be produced by chroma code renderer // this is list of <span> tag classes which could be produced by chroma code renderer
// source: https://github.com/alecthomas/chroma/blob/cc2dd5b/types.go#L211-L307 // source: https://github.com/alecthomas/chroma/blob/c263f6f/types.go#L209-L306
const codeSpanClassRegex = "^(bg|chroma|line|ln|lnt|hl|lntable|lntd|cl|w|err|x|k|kc" + const codeSpanClassRegex = "^(bg|chroma|line|ln|lnt|hl|lntable|lntd|lnlinks|cl|w|err|x|k|kc" +
"|kd|kn|kp|kr|kt|n|na|nb|bp|nc|no|nd|ni|ne|nf|fm|py|nl|nn|nx|nt|nv|vc|vg" + "|kd|kn|kp|kr|kt|n|na|nb|bp|nc|no|nd|ni|ne|nf|fm|py|nl|nn|nx|nt|nv|vc|vg" +
"|vi|vm|l|ld|s|sa|sb|sc|dl|sd|s2|se|sh|si|sx|sr|s1|ss|m|mb|mf|mh|mi|il" + "|vi|vm|l|ld|s|sa|sb|sc|dl|sd|s2|se|sh|si|sx|sr|s1|ss|m|mb|mf|mh|mi|il" +
"|mo|o|ow|p|c|ch|cm|cp|cpf|c1|cs|g|gd|ge|gr|gh|gi|go|gp|gs|gu|gt|gl)$" "|mo|o|ow|p|c|ch|cm|cp|cpf|c1|cs|g|gd|ge|gr|gh|gi|go|gp|gs|gu|gt|gl)$"
@@ -144,7 +147,7 @@ func (c *Comment) Snippet(limit int) string {
if size < limit { if size < limit {
return cleanText return cleanText
} }
snippet := []rune(cleanText)[:size] snippet := []rune(cleanText)[:limit]
// go back in snippet and found the first space // go back in snippet and found the first space
for i := len(snippet) - 1; i >= 0; i-- { for i := len(snippet) - 1; i >= 0; i-- {
if snippet[i] == ' ' { if snippet[i] == ' ' {
@@ -152,6 +155,10 @@ func (c *Comment) Snippet(limit int) string {
break break
} }
} }
// Don't add a space if comment is just a one single word which has been truncated.
if len(snippet) == limit {
return string(snippet) + "..."
}
return string(snippet) + " ..." return string(snippet) + " ..."
} }
@@ -176,8 +183,8 @@ func (c *Comment) escapeHTMLWithSome(inp string) string {
return res return res
} }
// SanitizeText used to sanitize any input string // SanitizeText used to sanitize any input string, and removes any HTML tags
func (c *Comment) SanitizeText(inp string) string { func (c *Comment) SanitizeText(inp string) string {
clean := bluemonday.UGCPolicy().Sanitize(inp) clean := bluemonday.StrictPolicy().Sanitize(inp)
return c.escapeHTMLWithSome(clean) return strings.TrimSpace(c.escapeHTMLWithSome(clean))
} }
+16 -4
View File
@@ -22,7 +22,7 @@ func TestComment_Sanitize(t *testing.T) {
}, },
out: Comment{ out: Comment{
Text: "blah XSS\n\t", Text: "blah XSS\n\t",
User: User{ID: `&lt;a href=&#34;http://blah.com&#34;&gt;username&lt;/a&gt;`, Name: "name &lt;b/&gt;"}, User: User{ID: `&lt;a href=&#34;http://blah.com&#34;&gt;username&lt;/a&gt;`, Name: "name"},
}, },
}, },
{ {
@@ -88,6 +88,18 @@ func TestComment_Sanitize(t *testing.T) {
inp: Comment{Text: "blah blah", PostTitle: "<script>alert()</script>something"}, inp: Comment{Text: "blah blah", PostTitle: "<script>alert()</script>something"},
out: Comment{Text: "blah blah", PostTitle: "something"}, out: Comment{Text: "blah blah", PostTitle: "something"},
}, },
{
inp: Comment{Text: "blah blah", PostTitle: "<a href=\"https://example.com\">test</a>"},
out: Comment{Text: "blah blah", PostTitle: "test"},
},
{
inp: Comment{Text: "blah blah", PostTitle: "https://example.com/blah"}, // link is left as-is, but not rendered as <a>
out: Comment{Text: "blah blah", PostTitle: "https://example.com/blah"},
},
{
inp: Comment{Text: `<blockquote class="twitter-tweet"><p lang="es" dir="ltr">Silicon iMac Concept<a href="https://t.co/7ga95QxVXn">https://t.co/7ga95QxVXn</a> by <a href="https://twitter.com/marcsheep?ref_src=twsrc%5Etfw">@marcsheep</a> <a href="https://t.co/ULnVpG8w55">pic.twitter.com/ULnVpG8w55</a></p>&mdash; Andreas Storm (@avstorm) <a href="https://twitter.com/avstorm/status/1325693387798933504?ref_src=twsrc%5Etfw">November 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`, PostTitle: "Twitter quote"},
out: Comment{Text: `<blockquote class="twitter-tweet"><p lang="es" dir="ltr">Silicon iMac Concept<a href="https://t.co/7ga95QxVXn" rel="nofollow">https://t.co/7ga95QxVXn</a> by <a href="https://twitter.com/marcsheep?ref_src=twsrc%5Etfw" rel="nofollow">@marcsheep</a> <a href="https://t.co/ULnVpG8w55" rel="nofollow">pic.twitter.com/ULnVpG8w55</a></p>— Andreas Storm (@avstorm) <a href="https://twitter.com/avstorm/status/1325693387798933504?ref_src=twsrc%5Etfw" rel="nofollow">November 9, 2020</a></blockquote> `, PostTitle: "Twitter quote"},
},
} }
for n, tt := range tbl { for n, tt := range tbl {
@@ -192,8 +204,8 @@ func TestComment_Snippet(t *testing.T) {
{0, "", ""}, {0, "", ""},
{-1, "test\nblah", "test blah"}, {-1, "test\nblah", "test blah"},
{5, "test\nblah", "test ..."}, {5, "test\nblah", "test ..."},
{5, "xyz12345 xxx", "xyz12345 ..."}, {5, "xyz12345 xxx", "xyz12..."},
{10, "xyz12345 xxx\ntest 123456", "xyz12345 xxx test ..."}, {10, "xyz12345 xxx\ntest 123456", "xyz12345 ..."},
} }
for i, tt := range tbl { for i, tt := range tbl {
@@ -259,7 +271,7 @@ func TestComment_sanitizeText(t *testing.T) {
}, },
{ {
"<a href=javascript:alert(document.domain)//>xxx</a>", "<a href=javascript:alert(document.domain)//>xxx</a>",
"&lt;a/&gt;xxx&lt;/a&gt;", "xxx",
}, },
} }
+9 -10
View File
@@ -170,7 +170,7 @@ func (b *BoltDB) Find(req FindRequest) (comments []store.Comment, err error) {
return e return e
} }
return bucket.ForEach(func(k, v []byte) error { return bucket.ForEach(func(_, v []byte) error {
comment := store.Comment{} comment := store.Comment{}
if e = json.Unmarshal(v, &comment); e != nil { if e = json.Unmarshal(v, &comment); e != nil {
return fmt.Errorf("failed to unmarshal: %w", e) return fmt.Errorf("failed to unmarshal: %w", e)
@@ -306,9 +306,8 @@ func (b *BoltDB) Info(req InfoRequest) ([]store.PostInfo, error) {
}) })
// set read-only from age and manual bucket // set read-only from age and manual bucket
readOnlyAge := req.ReadOnlyAge info.ReadOnly = req.ReadOnlyAge > 0 && !info.FirstTS.IsZero() && info.FirstTS.AddDate(0, 0, req.ReadOnlyAge).Before(time.Now())
info.ReadOnly = readOnlyAge > 0 && !info.FirstTS.IsZero() && info.FirstTS.AddDate(0, 0, readOnlyAge).Before(time.Now()) if !info.ReadOnly && b.checkFlag(FlagRequest{Locator: req.Locator, Flag: ReadOnly}) {
if b.checkFlag(FlagRequest{Locator: req.Locator, Flag: ReadOnly}) {
info.ReadOnly = true info.ReadOnly = true
} }
return []store.PostInfo{info}, err return []store.PostInfo{info}, err
@@ -425,11 +424,11 @@ func (b *BoltDB) Close() error {
} }
// Last returns up to max last comments for given siteID // Last returns up to max last comments for given siteID
func (b *BoltDB) lastComments(siteID string, max int, since time.Time) (comments []store.Comment, err error) { func (b *BoltDB) lastComments(siteID string, maximum int, since time.Time) (comments []store.Comment, err error) {
comments = []store.Comment{} comments = []store.Comment{}
if max > lastLimit || max == 0 { if maximum > lastLimit || maximum == 0 {
max = lastLimit maximum = lastLimit
} }
bdb, err := b.db(siteID) bdb, err := b.db(siteID)
@@ -467,7 +466,7 @@ func (b *BoltDB) lastComments(siteID string, max int, since time.Time) (comments
continue continue
} }
comments = append(comments, comment) comments = append(comments, comment)
if len(comments) >= max { if len(comments) >= maximum {
break break
} }
} }
@@ -725,7 +724,7 @@ func (b *BoltDB) listDetails(loc store.Locator) (result []UserDetailEntry, err e
err = bdb.View(func(tx *bolt.Tx) error { err = bdb.View(func(tx *bolt.Tx) error {
var entry UserDetailEntry var entry UserDetailEntry
bucket := tx.Bucket([]byte(userDetailsBucketName)) bucket := tx.Bucket([]byte(userDetailsBucketName))
return bucket.ForEach(func(userID, value []byte) error { return bucket.ForEach(func(_, value []byte) error {
if err = json.Unmarshal(value, &entry); err != nil { if err = json.Unmarshal(value, &entry); err != nil {
return fmt.Errorf("failed to unmarshal entry: %w", e) return fmt.Errorf("failed to unmarshal entry: %w", e)
} }
@@ -870,7 +869,7 @@ func (b *BoltDB) deleteUser(bdb *bolt.DB, siteID, userID string, mode store.Dele
err = bdb.View(func(tx *bolt.Tx) error { err = bdb.View(func(tx *bolt.Tx) error {
postsBkt := tx.Bucket([]byte(postsBucketName)) postsBkt := tx.Bucket([]byte(postsBucketName))
postBkt := postsBkt.Bucket([]byte(postInfo.URL)) postBkt := postsBkt.Bucket([]byte(postInfo.URL))
err = postBkt.ForEach(func(postURL []byte, commentVal []byte) error { err = postBkt.ForEach(func(_ []byte, commentVal []byte) error {
comment := store.Comment{} comment := store.Comment{}
if err = json.Unmarshal(commentVal, &comment); err != nil { if err = json.Unmarshal(commentVal, &comment); err != nil {
return fmt.Errorf("failed to unmarshal: %w", err) return fmt.Errorf("failed to unmarshal: %w", err)
+7
View File
@@ -911,6 +911,13 @@ func TestBoltDB_NewFailed(t *testing.T) {
assert.EqualError(t, err, "failed to make boltdb for /tmp/no-such-place/tmp.db: open /tmp/no-such-place/tmp.db: no such file or directory") assert.EqualError(t, err, "failed to make boltdb for /tmp/no-such-place/tmp.db: open /tmp/no-such-place/tmp.db: no such file or directory")
} }
func TestBoltDB_DoubleClose(t *testing.T) {
var b, teardown = prep(t)
defer teardown()
assert.NoError(t, b.Close())
assert.NoError(t, b.Close(), "second call should not result in panic or errors")
}
// makes new boltdb, put two records // makes new boltdb, put two records
func prep(t *testing.T) (b *BoltDB, teardown func()) { func prep(t *testing.T) (b *BoltDB, teardown func()) {
_ = os.Remove(testDB) _ = os.Remove(testDB)
+9 -3
View File
@@ -186,11 +186,17 @@ func TestRemote_Delete(t *testing.T) {
} }
func TestRemote_Close(t *testing.T) { func TestRemote_Close(t *testing.T) {
ts := testServer(t, `{"method":"store.close","id":1}`, `{}`) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "{\"method\":\"store.close\",\"id\":")
t.Logf("req: %s", string(body))
_, _ = fmt.Fprint(w, "{}")
}))
defer ts.Close() defer ts.Close()
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}} c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
err := c.Close() assert.NoError(t, c.Close())
assert.NoError(t, err) assert.NoError(t, c.Close(), "second call should not result in panic or errors")
} }
func testServer(t *testing.T, req, resp string) *httptest.Server { func testServer(t *testing.T, req, resp string) *httptest.Server {
+32 -19
View File
@@ -35,24 +35,17 @@ func NewCommentFormatter(converters ...CommentConverter) *CommentFormatter {
} }
// Format comment fields // Format comment fields
func (f *CommentFormatter) Format(c Comment) Comment { func (f *CommentFormatter) Format(c Comment, raw bool) Comment {
c.Text = f.FormatText(c.Text) c.Text = f.FormatText(c.Text, raw)
return c return c
} }
// FormatText converts text with markdown processor, applies external converters and shortens links // FormatText converts text with markdown processor, applies external converters and shortens links
func (f *CommentFormatter) FormatText(txt string) (res string) { //
mdExt := bf.NoIntraEmphasis | bf.Tables | bf.FencedCode | // raw=true disables SmartyPants for HTML rendering (replacement of quotes, dashes, fractions, etc).
bf.Strikethrough | bf.SpaceHeadings | bf.HardLineBreak | func (f *CommentFormatter) FormatText(txt string, raw bool) (res string) {
bf.BackslashLineBreak | bf.Autolink mdExt, rend := GetMdExtensionsAndRenderer(raw)
res = string(bf.Run([]byte(txt), bf.WithExtensions(mdExt), bf.WithRenderer(rend)))
rend := bf.NewHTMLRenderer(bf.HTMLRendererParameters{
Flags: bf.Smartypants | bf.SmartypantsFractions | bf.SmartypantsDashes | bf.SmartypantsAngledQuotes,
})
extRend := bfchroma.NewRenderer(bfchroma.Extend(rend), bfchroma.ChromaOptions(html.WithClasses(true)))
res = string(bf.Run([]byte(txt), bf.WithExtensions(mdExt), bf.WithRenderer(extRend)))
res = f.unEscape(res) res = f.unEscape(res)
for _, conv := range f.converters { for _, conv := range f.converters {
@@ -64,14 +57,14 @@ func (f *CommentFormatter) FormatText(txt string) (res string) {
} }
// Shortens all the automatic links in HTML: auto link has equal "href" and "text" attributes. // Shortens all the automatic links in HTML: auto link has equal "href" and "text" attributes.
func (f *CommentFormatter) shortenAutoLinks(commentHTML string, max int) (resHTML string) { func (f *CommentFormatter) shortenAutoLinks(commentHTML string, maximum int) (resHTML string) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML)) doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML))
if err != nil { if err != nil {
return commentHTML return commentHTML
} }
doc.Find("a").Each(func(i int, s *goquery.Selection) { doc.Find("a").Each(func(_ int, s *goquery.Selection) {
if href, ok := s.Attr("href"); ok { if href, ok := s.Attr("href"); ok {
if href != s.Text() || len(href) < max+3 || max < 3 { if href != s.Text() || len(href) < maximum+3 || maximum < 3 {
return return
} }
commentURL, e := url.Parse(href) commentURL, e := url.Parse(href)
@@ -84,7 +77,7 @@ func (f *CommentFormatter) shortenAutoLinks(commentHTML string, max int) (resHTM
return return
} }
short := string([]rune(href)[:max-3]) short := string([]rune(href)[:maximum-3])
if len(short) < len(host) { if len(short) < len(host) {
short = host short = host
} }
@@ -117,7 +110,7 @@ func (f *CommentFormatter) lazyImage(commentHTML string) (resHTML string) {
if err != nil { if err != nil {
return commentHTML return commentHTML
} }
doc.Find("img").Each(func(i int, s *goquery.Selection) { doc.Find("img").Each(func(_ int, s *goquery.Selection) {
s.SetAttr("loading", "lazy") s.SetAttr("loading", "lazy")
}) })
resHTML, err = doc.Find("body").Html() resHTML, err = doc.Find("body").Html()
@@ -126,3 +119,23 @@ func (f *CommentFormatter) lazyImage(commentHTML string) (resHTML string) {
} }
return resHTML return resHTML
} }
// GetMdExtensionsAndRenderer returns blackfriday extensions and renderer used for rendering markdown
// within store module.
//
// raw=true disables SmartyPants for HTML rendering (replacement of quotes, dashes, fractions, etc).
func GetMdExtensionsAndRenderer(raw bool) (bf.Extensions, *bfchroma.Renderer) {
mdExt := bf.NoIntraEmphasis | bf.Tables | bf.FencedCode |
bf.Strikethrough | bf.SpaceHeadings | bf.HardLineBreak |
bf.BackslashLineBreak | bf.Autolink
flags := bf.HTMLFlags(0)
if !raw {
flags = bf.Smartypants | bf.SmartypantsFractions | bf.SmartypantsDashes | bf.SmartypantsAngledQuotes
}
rend := bf.NewHTMLRenderer(bf.HTMLRendererParameters{Flags: flags})
extRend := bfchroma.NewRenderer(bfchroma.Extend(rend), bfchroma.ChromaOptions(html.WithClasses(true)))
return mdExt, extRend
}
+15 -6
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@@ -35,30 +36,38 @@ func TestFormatter_FormatText(t *testing.T) {
"lazy image", "lazy image",
}, },
{"&mdash; not translated #354", "<p>— not translated #354</p>\n!converted", "mdash"}, {"&mdash; not translated #354", "<p>— not translated #354</p>\n!converted", "mdash"},
{`no_smartpants "quoted" text`, "<p>no_smartpants &#34;quoted&#34; text</p>\n!converted", "normal quotes without smartpants"},
{`"quoted" text`, "<p>«quoted» text</p>\n!converted", "normal quotes with smartpants"},
{`no_smartpants “quoted” text`, "<p>no_smartpants “quoted” text</p>\n!converted", "curly quotes without smartpants"},
{`“quoted” text`, "<p>“quoted” text</p>\n!converted", "curly quotes with smartpants"},
{`no_smartpants «quoted» text`, "<p>no_smartpants «quoted» text</p>\n!converted", "French guillemets without smartpants"},
{`«quoted» text`, "<p>«quoted» text</p>\n!converted", "French guillemets with smartpants"},
{"smth\n```go\nfunc main(aa string) int {return 0}\n```", `<p>smth</p> {"smth\n```go\nfunc main(aa string) int {return 0}\n```", `<p>smth</p>
<pre tabindex="0" class="chroma"><code><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">(</span><span class="nx">aa</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span><span class="k">return</span> <span class="mi">0</span><span class="p">}</span> <pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">(</span><span class="nx">aa</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span><span class="k">return</span> <span class="mi">0</span><span class="p">}</span>
</span></span></code></pre>!converted`, "code with language"}, </span></span></code></pre>!converted`, "code with language"},
{"```\ntest_code\n```", `<pre tabindex="0" class="chroma"><code><span class="line"><span class="cl">test_code {"```\ntest_code\n```", `<pre class="chroma"><code><span class="line"><span class="cl">test_code
</span></span></code></pre>!converted`, "code without language"}, </span></span></code></pre>!converted`, "code without language"},
} }
f := NewCommentFormatter(mockConverter{}) f := NewCommentFormatter(mockConverter{})
for _, tt := range tbl { for _, tt := range tbl {
tt := tt tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.out, f.FormatText(tt.in)) raw := strings.HasPrefix(tt.in, `no_smartpants`)
t.Logf("raw: %v", raw)
assert.Equal(t, tt.out, f.FormatText(tt.in, raw))
}) })
} }
} }
func TestFormatter_FormatTextNoConverter(t *testing.T) { func TestFormatter_FormatTextNoConverter(t *testing.T) {
f := NewCommentFormatter() f := NewCommentFormatter()
assert.Equal(t, "<p>12345</p>\n", f.FormatText("12345")) assert.Equal(t, "<p>12345</p>\n", f.FormatText("12345", false))
} }
func TestFormatter_FormatTextConverterFunc(t *testing.T) { func TestFormatter_FormatTextConverterFunc(t *testing.T) {
fn := CommentConverterFunc(func(text string) string { return "zz!" + text }) fn := CommentConverterFunc(func(text string) string { return "zz!" + text })
f := NewCommentFormatter(fn) f := NewCommentFormatter(fn)
assert.Equal(t, "zz!<p>12345</p>\n", f.FormatText("12345")) assert.Equal(t, "zz!<p>12345</p>\n", f.FormatText("12345", false))
} }
func TestFormatter_FormatComment(t *testing.T) { func TestFormatter_FormatComment(t *testing.T) {
@@ -78,7 +87,7 @@ func TestFormatter_FormatComment(t *testing.T) {
f := NewCommentFormatter(mockConverter{}) f := NewCommentFormatter(mockConverter{})
exp := comment exp := comment
exp.Text = "<p>blah</p>\n\n<p>xyz</p>\n!converted" exp.Text = "<p>blah</p>\n\n<p>xyz</p>\n!converted"
assert.Equal(t, exp, f.Format(comment)) assert.Equal(t, exp, f.Format(comment, false))
} }
func TestFormatter_ShortenAutoLinks(t *testing.T) { func TestFormatter_ShortenAutoLinks(t *testing.T) {
+13
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"time" "time"
@@ -118,6 +119,18 @@ func (b *Bolt) Load(id string) ([]byte, error) {
return data, nil return data, nil
} }
// Delete image from storage
func (b *Bolt) Delete(id string) error {
return b.db.Update(func(tx *bolt.Tx) error {
// deleting a non-existing key doesn't return an error, so joining errors from deleting an image
// from both buckets is safe and will return nil if there are no errors on the real delete
// or image is absent in both buckets
err := tx.Bucket([]byte(imagesBktName)).Delete([]byte(id))
err = errors.Join(err, tx.Bucket([]byte(imagesStagedBktName)).Delete([]byte(id)))
return err
})
}
// Cleanup runs scan of staging and removes old data based on ttl // Cleanup runs scan of staging and removes old data based on ttl
func (b *Bolt) Cleanup(_ context.Context, ttl time.Duration) error { func (b *Bolt) Cleanup(_ context.Context, ttl time.Duration) error {
return b.db.Update(func(tx *bolt.Tx) error { return b.db.Update(func(tx *bolt.Tx) error {
@@ -58,6 +58,36 @@ func TestBoltStore_LoadAfterSave(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
func TestBoltStore_LoadAfterDelete(t *testing.T) {
svc, teardown := prepareBoltImageStorageTest(t)
defer teardown()
// delete image from permanent storage
id := "test_img"
err := svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)
err = svc.Commit(id)
require.NoError(t, err)
err = svc.Delete(id)
assert.NoError(t, err)
_, err = svc.Load(id)
assert.Error(t, err)
// delete staging image
id = "staging_img"
err = svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)
err = svc.Delete(id)
assert.NoError(t, err)
_, err = svc.Load(id)
assert.Error(t, err)
}
func TestBoltStore_Cleanup(t *testing.T) { func TestBoltStore_Cleanup(t *testing.T) {
svc, teardown := prepareBoltImageStorageTest(t) svc, teardown := prepareBoltImageStorageTest(t)
defer teardown() defer teardown()
+25 -2
View File
@@ -23,7 +23,8 @@ type FileSystem struct {
Staging string Staging string
Partitions int Partitions int
crc struct { moveLock sync.Mutex // needed only for deleting images or moving them from staging to permanent storage
crc struct {
*crc64.Table *crc64.Table
sync.Once sync.Once
mask string mask string
@@ -50,6 +51,8 @@ func (f *FileSystem) Save(id string, img []byte) error {
// Commit file stored in staging location by moving it to permanent location // Commit file stored in staging location by moving it to permanent location
func (f *FileSystem) Commit(id string) error { func (f *FileSystem) Commit(id string) error {
f.moveLock.Lock()
defer f.moveLock.Unlock()
log.Printf("[DEBUG] Commit image %s", id) log.Printf("[DEBUG] Commit image %s", id)
stagingImage, permImage := f.location(f.Staging, id), f.location(f.Location, id) stagingImage, permImage := f.location(f.Staging, id), f.location(f.Location, id)
@@ -107,11 +110,31 @@ func (f *FileSystem) Load(id string) ([]byte, error) {
return io.ReadAll(fh) return io.ReadAll(fh)
} }
// Delete image from storage
func (f *FileSystem) Delete(id string) error {
f.moveLock.Lock()
defer f.moveLock.Unlock()
staging := f.location(f.Staging, id)
// file doesn't exist on staging, delete from permanent location
if _, err := os.Stat(staging); os.IsNotExist(err) {
file := f.location(f.Location, id)
e := os.Remove(file)
_ = os.Remove(path.Dir(file)) // try to remove directory
return e
}
// delete file from staging
err := os.Remove(staging)
_ = os.Remove(path.Dir(staging)) // try to remove directory
return err
}
// Cleanup runs scan of staging and removes old files based on ttl // Cleanup runs scan of staging and removes old files based on ttl
func (f *FileSystem) Cleanup(_ context.Context, ttl time.Duration) error { func (f *FileSystem) Cleanup(_ context.Context, ttl time.Duration) error {
if _, err := os.Stat(f.Staging); os.IsNotExist(err) { if _, err := os.Stat(f.Staging); os.IsNotExist(err) {
return nil return nil
} }
f.moveLock.Lock()
defer f.moveLock.Unlock()
// we can ignore context as on local FS remove is relatively fast operation // we can ignore context as on local FS remove is relatively fast operation
err := filepath.Walk(f.Staging, func(fpath string, info os.FileInfo, err error) error { err := filepath.Walk(f.Staging, func(fpath string, info os.FileInfo, err error) error {
@@ -143,7 +166,7 @@ func (f *FileSystem) Info() (StoreInfo, error) {
} }
var ts time.Time var ts time.Time
err := filepath.Walk(f.Staging, func(fpath string, info os.FileInfo, err error) error { err := filepath.Walk(f.Staging, func(_ string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
+25
View File
@@ -131,6 +131,31 @@ func TestFsStore_LoadAfterCommit(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
func TestFsStore_LoadAfterDelete(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
id := "test_img"
err := svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)
err = svc.Commit(id)
require.NoError(t, err)
err = svc.Delete(id)
require.NoError(t, err)
_, err = svc.Load(id)
assert.Error(t, err)
// create file on staging
err = svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)
err = svc.Delete(id)
require.NoError(t, err)
_, err = svc.Load(id)
assert.Error(t, err)
}
func TestFsStore_location(t *testing.T) { func TestFsStore_location(t *testing.T) {
tbl := []struct { tbl := []struct {
partitions int partitions int
+53 -35
View File
@@ -70,6 +70,7 @@ type Store interface {
Info() (StoreInfo, error) // get meta information about storage Info() (StoreInfo, error) // get meta information about storage
Save(id string, img []byte) error // store image with passed id to staging Save(id string, img []byte) error // store image with passed id to staging
Load(id string) ([]byte, error) // load image by ID Load(id string) ([]byte, error) // load image by ID
Delete(id string) error // delete image by ID
ResetCleanupTimer(id string) error // resets cleanup timer for the image, called on comment preview ResetCleanupTimer(id string) error // resets cleanup timer for the image, called on comment preview
Commit(id string) error // move image from staging to permanent Commit(id string) error // move image from staging to permanent
@@ -88,8 +89,8 @@ func NewService(s Store, p ServiceParams) *Service {
return &Service{ServiceParams: p, store: s} return &Service{ServiceParams: p, store: s}
} }
// SubmitAndCommit multiple ids immediately // Commit multiple ids immediately
func (s *Service) SubmitAndCommit(idsFn func() []string) error { func (s *Service) Commit(idsFn func() []string) error {
errs := new(multierror.Error) errs := new(multierror.Error)
for _, id := range idsFn() { for _, id := range idsFn() {
err := s.store.Commit(id) err := s.store.Commit(id)
@@ -117,7 +118,7 @@ func (s *Service) Submit(idsFn func() []string) {
for atomic.LoadInt32(&s.term) == 0 && time.Since(req.TS) <= s.EditDuration { for atomic.LoadInt32(&s.term) == 0 && time.Since(req.TS) <= s.EditDuration {
time.Sleep(time.Millisecond * 10) // small sleep to relive busy wait but keep reactive for term (close) time.Sleep(time.Millisecond * 10) // small sleep to relive busy wait but keep reactive for term (close)
} }
err := s.SubmitAndCommit(req.idsFn) err := s.Commit(req.idsFn)
if err != nil { if err != nil {
log.Printf("[WARN] image commit error %v", err) log.Printf("[WARN] image commit error %v", err)
} }
@@ -141,39 +142,14 @@ func (s *Service) Submit(idsFn func() []string) {
// ExtractPictures gets list of images from the doc html and convert from urls to ids, i.e. user/pic.png // ExtractPictures gets list of images from the doc html and convert from urls to ids, i.e. user/pic.png
func (s *Service) ExtractPictures(commentHTML string) (ids []string) { func (s *Service) ExtractPictures(commentHTML string) (ids []string) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML)) return s.extractImageIDs(commentHTML, true)
if err != nil { }
log.Printf("[ERROR] can't parse commentHTML to parse images: %q, error: %v", commentHTML, err)
return nil
}
doc.Find("img").Each(func(i int, sl *goquery.Selection) {
if im, ok := sl.Attr("src"); ok {
if strings.Contains(im, s.ImageAPI) {
elems := strings.Split(im, "/")
if len(elems) >= 2 {
id := elems[len(elems)-2] + "/" + elems[len(elems)-1]
ids = append(ids, id)
}
}
if strings.Contains(im, s.ProxyAPI) {
proxiedURL, err := url.Parse(im)
if err != nil {
return
}
imgURL, err := base64.URLEncoding.DecodeString(proxiedURL.Query().Get("src"))
if err != nil {
return
}
imgID, err := CachedImgID(string(imgURL))
if err != nil {
return
}
ids = append(ids, imgID)
}
}
})
return ids // ExtractNonProxiedPictures gets list of non-proxied images from the doc html and convert from urls to ids, i.e. user/pic.png
// This method is used in image check on post preview and load, as proxied images have lazy loading
// and wouldn't be present on disk but still valid as they will be loaded the first time someone requests them.
func (s *Service) ExtractNonProxiedPictures(commentHTML string) (ids []string) {
return s.extractImageIDs(commentHTML, false)
} }
// Cleanup runs periodic cleanup with 1.5*ServiceParams.EditDuration. Blocking loop, should be called inside of goroutine by consumer // Cleanup runs periodic cleanup with 1.5*ServiceParams.EditDuration. Blocking loop, should be called inside of goroutine by consumer
@@ -237,6 +213,11 @@ func (s *Service) Load(id string) ([]byte, error) {
return s.store.Load(id) return s.store.Load(id)
} }
// Delete wraps storage Delete function.
func (s *Service) Delete(id string) error {
return s.store.Delete(id)
}
// Save wraps storage Save function, validating and resizing the image before calling it. // Save wraps storage Save function, validating and resizing the image before calling it.
func (s *Service) Save(userID string, r io.Reader) (id string, err error) { func (s *Service) Save(userID string, r io.Reader) (id string, err error) {
id = path.Join(userID, guid()) id = path.Join(userID, guid())
@@ -262,6 +243,43 @@ func (s *Service) ImgContentType(img []byte) string {
return contentType return contentType
} }
// returns list of image IDs from the comment html, including proxied images if includeProxied is true
func (s *Service) extractImageIDs(commentHTML string, includeProxied bool) (ids []string) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML))
if err != nil {
log.Printf("[ERROR] can't parse commentHTML to parse images: %q, error: %v", commentHTML, err)
return nil
}
doc.Find("img").Each(func(_ int, sl *goquery.Selection) {
if im, ok := sl.Attr("src"); ok {
if strings.Contains(im, s.ImageAPI) {
elems := strings.Split(im, "/")
if len(elems) >= 2 {
id := elems[len(elems)-2] + "/" + elems[len(elems)-1]
ids = append(ids, id)
}
}
if includeProxied && strings.Contains(im, s.ProxyAPI) {
proxiedURL, err := url.Parse(im)
if err != nil {
return
}
imgURL, err := base64.URLEncoding.DecodeString(proxiedURL.Query().Get("src"))
if err != nil {
return
}
imgID, err := CachedImgID(string(imgURL))
if err != nil {
return
}
ids = append(ids, imgID)
}
}
})
return ids
}
// prepareImage calls readAndValidateImage and resize on provided image. // prepareImage calls readAndValidateImage and resize on provided image.
func (s *Service) prepareImage(r io.Reader) ([]byte, error) { func (s *Service) prepareImage(r io.Reader) ([]byte, error) {
data, err := readAndValidateImage(r, s.MaxSize) data, err := readAndValidateImage(r, s.MaxSize)
+46 -2
View File
@@ -4,9 +4,9 @@
package image package image
import ( import (
context "context" "context"
"sync" "sync"
time "time" "time"
) )
// Ensure, that StoreMock does implement Store. // Ensure, that StoreMock does implement Store.
@@ -25,6 +25,9 @@ var _ Store = &StoreMock{}
// CommitFunc: func(id string) error { // CommitFunc: func(id string) error {
// panic("mock out the Commit method") // panic("mock out the Commit method")
// }, // },
// DeleteFunc: func(id string) error {
// panic("mock out the Delete method")
// },
// InfoFunc: func() (StoreInfo, error) { // InfoFunc: func() (StoreInfo, error) {
// panic("mock out the Info method") // panic("mock out the Info method")
// }, // },
@@ -50,6 +53,9 @@ type StoreMock struct {
// CommitFunc mocks the Commit method. // CommitFunc mocks the Commit method.
CommitFunc func(id string) error CommitFunc func(id string) error
// DeleteFunc mocks the Delete method.
DeleteFunc func(id string) error
// InfoFunc mocks the Info method. // InfoFunc mocks the Info method.
InfoFunc func() (StoreInfo, error) InfoFunc func() (StoreInfo, error)
@@ -76,6 +82,11 @@ type StoreMock struct {
// ID is the id argument value. // ID is the id argument value.
ID string ID string
} }
// Delete holds details about calls to the Delete method.
Delete []struct {
// ID is the id argument value.
ID string
}
// Info holds details about calls to the Info method. // Info holds details about calls to the Info method.
Info []struct { Info []struct {
} }
@@ -99,6 +110,7 @@ type StoreMock struct {
} }
lockCleanup sync.RWMutex lockCleanup sync.RWMutex
lockCommit sync.RWMutex lockCommit sync.RWMutex
lockDelete sync.RWMutex
lockInfo sync.RWMutex lockInfo sync.RWMutex
lockLoad sync.RWMutex lockLoad sync.RWMutex
lockResetCleanupTimer sync.RWMutex lockResetCleanupTimer sync.RWMutex
@@ -173,6 +185,38 @@ func (mock *StoreMock) CommitCalls() []struct {
return calls return calls
} }
// Delete calls DeleteFunc.
func (mock *StoreMock) Delete(id string) error {
if mock.DeleteFunc == nil {
panic("StoreMock.DeleteFunc: method is nil but Store.Delete was just called")
}
callInfo := struct {
ID string
}{
ID: id,
}
mock.lockDelete.Lock()
mock.calls.Delete = append(mock.calls.Delete, callInfo)
mock.lockDelete.Unlock()
return mock.DeleteFunc(id)
}
// DeleteCalls gets all the calls that were made to Delete.
// Check the length with:
//
// len(mockedStore.DeleteCalls())
func (mock *StoreMock) DeleteCalls() []struct {
ID string
} {
var calls []struct {
ID string
}
mock.lockDelete.RLock()
calls = mock.calls.Delete
mock.lockDelete.RUnlock()
return calls
}
// Info calls InfoFunc. // Info calls InfoFunc.
func (mock *StoreMock) Info() (StoreInfo, error) { func (mock *StoreMock) Info() (StoreInfo, error) {
if mock.InfoFunc == nil { if mock.InfoFunc == nil {
+19 -20
View File
@@ -19,10 +19,10 @@ import (
func TestService_SaveAndLoad(t *testing.T) { func TestService_SaveAndLoad(t *testing.T) {
store := StoreMock{ store := StoreMock{
SaveFunc: func(id string, img []byte) error { SaveFunc: func(string, []byte) error {
return nil return nil
}, },
LoadFunc: func(id string) ([]byte, error) { LoadFunc: func(string) ([]byte, error) {
return nil, nil return nil, nil
}, },
} }
@@ -105,6 +105,7 @@ func TestService_ExtractPictures(t *testing.T) {
ids = svc.ExtractPictures(html) ids = svc.ExtractPictures(html)
require.Equal(t, 1, len(ids), "one image in") require.Equal(t, 1, len(ids), "one image in")
assert.Equal(t, "cached_images/12318fbd4c55e9d177b8b5ae197bc89c5afd8e07-a41fcb00643f28d700504256ec81cbf2e1aac53e", ids[0]) assert.Equal(t, "cached_images/12318fbd4c55e9d177b8b5ae197bc89c5afd8e07-a41fcb00643f28d700504256ec81cbf2e1aac53e", ids[0])
require.Empty(t, svc.ExtractNonProxiedPictures(html), "no non-proxied images expected to be found")
// bad url // bad url
html = `<img src=" https://remark42.radio-t.com/api/v1/img">` html = `<img src=" https://remark42.radio-t.com/api/v1/img">`
@@ -125,7 +126,7 @@ func TestService_ExtractPictures(t *testing.T) {
func TestService_Cleanup(t *testing.T) { func TestService_Cleanup(t *testing.T) {
store := StoreMock{ store := StoreMock{
CleanupFunc: func(ctx context.Context, ttl time.Duration) error { CleanupFunc: func(context.Context, time.Duration) error {
return nil return nil
}, },
} }
@@ -140,17 +141,13 @@ func TestService_Cleanup(t *testing.T) {
func TestService_Submit(t *testing.T) { func TestService_Submit(t *testing.T) {
store := StoreMock{ store := StoreMock{
CommitFunc: func(id string) error { CommitFunc: func(string) error { return nil },
return nil ResetCleanupTimerFunc: func(string) error { return nil },
},
ResetCleanupTimerFunc: func(id string) error {
return nil
},
} }
svc := NewService(&store, ServiceParams{ImageAPI: "/blah/", EditDuration: time.Millisecond * 100}) svc := NewService(&store, ServiceParams{ImageAPI: "/blah/", EditDuration: time.Millisecond * 100})
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} }) svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
assert.Equal(t, 3, len(store.ResetCleanupTimerCalls())) assert.Equal(t, 3, len(store.ResetCleanupTimerCalls()))
err := svc.SubmitAndCommit(func() []string { return []string{"id4", "id5"} }) err := svc.Commit(func() []string { return []string{"id4", "id5"} })
assert.NoError(t, err) assert.NoError(t, err)
svc.Submit(func() []string { return []string{"id6", "id7"} }) svc.Submit(func() []string { return []string{"id6", "id7"} })
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls())) assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
@@ -163,12 +160,8 @@ func TestService_Submit(t *testing.T) {
func TestService_Close(t *testing.T) { func TestService_Close(t *testing.T) {
store := StoreMock{ store := StoreMock{
CommitFunc: func(id string) error { CommitFunc: func(string) error { return nil },
return nil ResetCleanupTimerFunc: func(string) error { return nil },
},
ResetCleanupTimerFunc: func(id string) error {
return nil
},
} }
svc := Service{store: &store, ServiceParams: ServiceParams{ImageAPI: "/blah/", EditDuration: time.Hour * 24}} svc := Service{store: &store, ServiceParams: ServiceParams{ImageAPI: "/blah/", EditDuration: time.Hour * 24}}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} }) svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
@@ -181,10 +174,8 @@ func TestService_Close(t *testing.T) {
func TestService_SubmitDelay(t *testing.T) { func TestService_SubmitDelay(t *testing.T) {
store := StoreMock{ store := StoreMock{
CommitFunc: func(id string) error { CommitFunc: func(string) error { return nil },
return nil ResetCleanupTimerFunc: func(string) error {
},
ResetCleanupTimerFunc: func(id string) error {
return nil return nil
}, },
} }
@@ -286,3 +277,11 @@ func TestCachedImgID(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "cached_images/"+Sha1Str("example.org")+"-"+Sha1Str(imgURL), img) assert.Equal(t, "cached_images/"+Sha1Str("example.org")+"-"+Sha1Str(imgURL), img)
} }
func TestService_DoubleClose(*testing.T) {
store := StoreMock{}
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
svc.Close(context.TODO())
// second call should not result in panic
svc.Close(context.TODO())
}
+6
View File
@@ -41,6 +41,12 @@ func (r *RPC) Load(id string) ([]byte, error) {
return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawImg))) return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawImg)))
} }
// Delete image from storage
func (r *RPC) Delete(id string) error {
_, err := r.Call("image.delete", id)
return err
}
// Commit file stored in staging location by moving it to permanent location // Commit file stored in staging location by moving it to permanent location
func (r *RPC) Commit(id string) error { func (r *RPC) Commit(id string) error {
_, err := r.Call("image.commit", id) _, err := r.Call("image.commit", id)
@@ -41,6 +41,18 @@ func TestRemote_Load(t *testing.T) {
assert.Equal(t, gopherPNGBytes(), res) assert.Equal(t, gopherPNGBytes(), res)
} }
func TestRemote_Delete(t *testing.T) {
ts := testServer(t, `{"method":"image.delete","params":"54321","id":1}`, `{}`)
defer ts.Close()
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
var a Store = &c
_ = a
err := c.Delete("54321")
assert.NoError(t, err)
}
func TestRemote_Commit(t *testing.T) { func TestRemote_Commit(t *testing.T) {
ts := testServer(t, `{"method":"image.commit","params":"gopher_id","id":1}`, `{"id":1}`) ts := testServer(t, `{"method":"image.commit","params":"gopher_id","id":1}`, `{"id":1}`)
defer ts.Close() defer ts.Close()
+81 -11
View File
@@ -5,15 +5,17 @@ package service
import ( import (
"fmt" "fmt"
"math" "math"
"slices"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/go-pkgz/lcw" "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hashicorp/go-multierror" "github.com/hashicorp/go-multierror"
bf "github.com/russross/blackfriday/v2"
"github.com/umputun/remark42/backend/app/store" "github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/admin" "github.com/umputun/remark42/backend/app/store/admin"
@@ -26,6 +28,7 @@ type DataStore struct {
Engine engine.Interface Engine engine.Interface
EditDuration time.Duration EditDuration time.Duration
AdminStore admin.Store AdminStore admin.Store
MinCommentSize int
MaxCommentSize int MaxCommentSize int
MaxVotes int MaxVotes int
RestrictSameIPVotes struct { RestrictSameIPVotes struct {
@@ -46,7 +49,7 @@ type DataStore struct {
} }
repliesCache struct { repliesCache struct {
lcw.LoadingCache lcw.LoadingCache[struct{}]
once sync.Once once sync.Once
} }
} }
@@ -68,7 +71,7 @@ type PostMetaData struct {
ReadOnly bool `json:"read_only"` ReadOnly bool `json:"read_only"`
} }
const defaultCommentMaxSize = 2000 const defaultCommentMaxSize = 2048
const maxLastCommentsReply = 5000 const maxLastCommentsReply = 5000
// UnlimitedVotes doesn't restrict MaxVotes // UnlimitedVotes doesn't restrict MaxVotes
@@ -278,7 +281,7 @@ func (s *DataStore) submitImages(comment store.Comment) {
var err error var err error
if comment.Imported { if comment.Imported {
err = s.ImageService.SubmitAndCommit(idsFn) err = s.ImageService.Commit(idsFn)
} else { } else {
s.ImageService.Submit(idsFn) s.ImageService.Submit(idsFn)
} }
@@ -531,7 +534,8 @@ func (s *DataStore) EditComment(locator store.Locator, commentID string, req Edi
func (s *DataStore) HasReplies(comment store.Comment) bool { func (s *DataStore) HasReplies(comment store.Comment) bool {
s.repliesCache.once.Do(func() { s.repliesCache.once.Do(func() {
// default expiration time of 5 minutes and cleanup time of 2.5 minutes // default expiration time of 5 minutes and cleanup time of 2.5 minutes
s.repliesCache.LoadingCache, _ = lcw.NewExpirableCache(lcw.TTL(5 * time.Minute)) o := lcw.NewOpts[struct{}]()
s.repliesCache.LoadingCache, _ = lcw.NewExpirableCache[struct{}](o.TTL(5 * time.Minute))
}) })
if _, found := s.repliesCache.Peek(comment.ID); found { if _, found := s.repliesCache.Peek(comment.ID); found {
@@ -551,7 +555,7 @@ func (s *DataStore) HasReplies(comment store.Comment) bool {
// When this code is reached, key "comment.ID" is not in cache. // When this code is reached, key "comment.ID" is not in cache.
// Calling cache.Get on it will put it in cache with 5 minutes TTL. // Calling cache.Get on it will put it in cache with 5 minutes TTL.
// We call it with empty struct as value as we care about keys and not values. // We call it with empty struct as value as we care about keys and not values.
_, _ = s.repliesCache.Get(comment.ID, func() (interface{}, error) { return struct{}{}, nil }) _, _ = s.repliesCache.Get(comment.ID, func() (struct{}, error) { return struct{}{}, nil })
return true return true
} }
} }
@@ -627,7 +631,9 @@ func (s *DataStore) Counts(siteID string, postIDs []string) ([]store.PostInfo, e
return res, nil return res, nil
} }
// ValidateComment checks if comment size below max and user fields set // ValidateComment checks if comment size below max and user fields set.
// It also validates the absence of relative links as they are almost never the intention of the commenter,
// usually added by mistakes and only create confusion.
func (s *DataStore) ValidateComment(c *store.Comment) error { func (s *DataStore) ValidateComment(c *store.Comment) error {
maxSize := s.MaxCommentSize maxSize := s.MaxCommentSize
if s.MaxCommentSize <= 0 { if s.MaxCommentSize <= 0 {
@@ -636,13 +642,33 @@ func (s *DataStore) ValidateComment(c *store.Comment) error {
if c.Orig == "" { if c.Orig == "" {
return fmt.Errorf("empty comment text") return fmt.Errorf("empty comment text")
} }
if len([]rune(c.Orig)) > maxSize { commentLength := len([]rune(c.Orig))
return fmt.Errorf("comment text exceeded max allowed size %d (%d)", maxSize, len([]rune(c.Orig))) if commentLength > maxSize {
return fmt.Errorf("comment text exceeded max allowed size %d (%d)", maxSize, commentLength)
}
if s.MinCommentSize > 0 && commentLength < s.MinCommentSize {
return fmt.Errorf("comment text is smaller than min allowed size %d (%d)", s.MinCommentSize, commentLength)
} }
if c.User.ID == "" || c.User.Name == "" { if c.User.ID == "" || c.User.Name == "" {
return fmt.Errorf("empty user info") return fmt.Errorf("empty user info")
} }
return nil
// for validation purposes it's not important if SmartyPants formatting is disabled or enabled,
// while for storing the comment that flag is set based on user preference
mdExt, rend := store.GetMdExtensionsAndRenderer(false)
parser := bf.New(bf.WithRenderer(rend), bf.WithExtensions(bf.CommonExtensions), bf.WithExtensions(mdExt))
var wrongLinkError error
parser.Parse([]byte(c.Orig)).Walk(func(node *bf.Node, _ bool) bf.WalkStatus {
if len(node.LinkData.Destination) != 0 &&
!(strings.HasPrefix(string(node.LinkData.Destination), "http://") ||
strings.HasPrefix(string(node.LinkData.Destination), "https://") ||
strings.HasPrefix(string(node.LinkData.Destination), "mailto:")) {
wrongLinkError = fmt.Errorf("links should start with mailto:, http:// or https://")
return bf.Terminate
}
return bf.GoToNext
})
return wrongLinkError
} }
// IsAdmin checks if usesID in the list of admins // IsAdmin checks if usesID in the list of admins
@@ -737,7 +763,23 @@ func (s *DataStore) Info(locator store.Locator, readonlyAge int) (store.PostInfo
if len(res) == 0 { if len(res) == 0 {
return store.PostInfo{}, fmt.Errorf("post %+v not found", locator) return store.PostInfo{}, fmt.Errorf("post %+v not found", locator)
} }
return res[0], nil // URL request
if locator.URL != "" {
return res[0], nil
}
// site-wide request which returned multiple store.PostInfo, so that URL and ReadOnly flags don't make sense
var info store.PostInfo
for _, i := range res {
info.Count += i.Count
if info.FirstTS.IsZero() || i.FirstTS.Before(info.FirstTS) {
info.FirstTS = i.FirstTS
}
if info.LastTS.IsZero() || i.LastTS.After(info.LastTS) {
info.LastTS = i.LastTS
}
}
return info, nil
} }
// Delete comment by id // Delete comment by id
@@ -756,6 +798,34 @@ func (s *DataStore) Delete(locator store.Locator, commentID string, mode store.D
s.repliesCache.Delete(commentID) s.repliesCache.Delete(commentID)
s.repliesCache.Delete(comment.ParentID) s.repliesCache.Delete(comment.ParentID)
} }
// delete images from the comment if they are not reused elsewhere in comments to the same page
idsFn := func() []string { // get IDs of all images from the same URL to verify if image from deleted comment was reused
comments, e := s.Engine.Find(engine.FindRequest{Locator: locator})
if e != nil {
log.Printf("[WARN] can't get comments %s text for deleted comment image check, %v", comment.ID, err)
return nil
}
var imgIDs = []string{}
for _, cc := range comments {
// exclude the comment we are deleting
if cc.ID != commentID {
imgIDs = append(imgIDs, s.ImageService.ExtractPictures(cc.Text)...)
}
}
return imgIDs
}
commentImgIDs := s.ImageService.ExtractPictures(comment.Text)
pageImgIDs := idsFn()
for _, id := range commentImgIDs {
if !slices.Contains(pageImgIDs, id) {
if err := s.ImageService.Delete(id); err != nil {
log.Printf("[WARN] failed to delete image %s on comment %s deletion, %v", id, commentID, err)
}
}
}
log.Printf("[ERROR] commentImgIDs: %v, pageImgIDs: %v", commentImgIDs, pageImgIDs)
req := engine.DeleteRequest{Locator: locator, CommentID: commentID, DeleteMode: mode} req := engine.DeleteRequest{Locator: locator, CommentID: commentID, DeleteMode: mode}
return s.Engine.Delete(req) return s.Engine.Delete(req)
} }
+309 -21
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http" "net/http"
@@ -131,7 +132,7 @@ func TestService_CreateFromPartialWithTitle(t *testing.T) {
eng, teardown := prepStoreEngine(t) eng, teardown := prepStoreEngine(t)
defer teardown() defer teardown()
b := DataStore{Engine: eng, AdminStore: ks, b := DataStore{Engine: eng, AdminStore: ks,
TitleExtractor: NewTitleExtractor(http.Client{Timeout: 5 * time.Second})} TitleExtractor: NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})}
defer b.Close() defer b.Close()
postPath := "/post/42" postPath := "/post/42"
@@ -171,6 +172,50 @@ func TestService_CreateFromPartialWithTitle(t *testing.T) {
assert.Equal(t, "post blah", res.PostTitle, "keep comment title") assert.Equal(t, "post blah", res.PostTitle, "keep comment title")
} }
func TestService_Put(t *testing.T) {
ks := admin.NewStaticKeyStore("secret 123")
eng, teardown := prepStoreEngine(t)
defer teardown()
b := DataStore{Engine: eng, AdminStore: ks}
comment := store.Comment{
ID: "c-1",
ParentID: "id-1",
Text: "test text",
User: store.User{ID: "user2", Name: "user name 2"},
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
}
_, err := b.Create(comment)
require.NoError(t, err)
// create new comment with everything different to replace the first one with fields below
updatedComment := store.Comment{
ID: "c-1",
ParentID: "id-new",
Text: "new text",
User: store.User{ID: "user3", Name: "user name 3"},
Locator: store.Locator{URL: "https://example.com", SiteID: "example"},
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
}
err = b.Put(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, updatedComment)
require.NoError(t, err)
// request with wrong user, should not affect the comment user
got, err := b.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "c-1", store.User{ID: "user1", Name: "user name 1"})
require.NoError(t, err)
assert.Equal(t, "c-1", got.ID)
assert.Equal(t, "new text", got.Text)
assert.Equal(t, "id-1", got.ParentID, "should be unaltered")
assert.Equal(t, "user2", got.User.ID, "should be unaltered")
assert.Equal(t, "user name 2", got.User.Name, "should be unaltered")
assert.Equal(t, "https://radio-t.com", got.Locator.URL, "should be unaltered")
assert.Equal(t, "radio-t", got.Locator.SiteID, "should be unaltered")
assert.Equal(t, time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local), got.Timestamp, "should be unaltered")
}
func TestService_SetTitle(t *testing.T) { func TestService_SetTitle(t *testing.T) {
var titleEnable int32 var titleEnable int32
tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -195,7 +240,7 @@ func TestService_SetTitle(t *testing.T) {
eng, teardown := prepStoreEngine(t) eng, teardown := prepStoreEngine(t)
defer teardown() defer teardown()
b := DataStore{Engine: eng, AdminStore: ks, b := DataStore{Engine: eng, AdminStore: ks,
TitleExtractor: NewTitleExtractor(http.Client{Timeout: 5 * time.Second})} TitleExtractor: NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})}
defer b.Close() defer b.Close()
comment := store.Comment{ comment := store.Comment{
Text: "text", Text: "text",
@@ -413,7 +458,6 @@ func TestService_VoteAggressive(t *testing.T) {
assert.Equal(t, 0, len(res[0].VotedIPs), "vote ips hidden") assert.Equal(t, 0, len(res[0].VotedIPs), "vote ips hidden")
// random +1/-1 result should be [0..2] // random +1/-1 result should be [0..2]
rand.Seed(time.Now().UnixNano())
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -546,6 +590,111 @@ func TestService_VoteControversy(t *testing.T) {
assert.InDelta(t, 1.73, res[0].Controversy, 0.01) assert.InDelta(t, 1.73, res[0].Controversy, 0.01)
} }
func TestService_RestrictedWords(t *testing.T) {
ks := admin.NewStaticKeyStore("secret 123")
eng, teardown := prepStoreEngine(t)
defer teardown()
restictedWordLister := StaticRestrictedWordsLister{Words: []string{"restricted"}}
b := DataStore{Engine: eng, AdminStore: ks, RestrictedWordsMatcher: NewRestrictedWordsMatcher(restictedWordLister)}
// test creating a comment with restricted words which should fail with appropriate error
reply := store.Comment{
ID: "c-1",
ParentID: "id-1",
Text: "restricted word",
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
User: store.User{ID: "user2", Name: "user name 2"},
}
id, err := b.Create(reply)
assert.EqualError(t, err, ErrRestrictedWordsFound.Error(), "should fail with RestrictedWordError")
assert.Empty(t, id)
}
func TestDataStore_AdminStoreErrors(t *testing.T) {
badKey := true
badEnabled := true
as := admin.StoreMock{
OnEventFunc: func(string, admin.EventType) error { return errors.New("err") },
KeyFunc: func(string) (string, error) {
if badKey {
return "", errors.New("mock key err")
}
return "secret", nil
},
EnabledFunc: func(string) (bool, error) {
if badEnabled {
return false, errors.New("mock enabled err")
}
return true, nil
},
AdminsFunc: func(string) ([]string, error) { return nil, errors.New("mock admins err") },
}
eng, teardown := prepStoreEngine(t)
defer teardown()
b := DataStore{Engine: eng, AdminStore: &as, MaxVotes: -1}
comment := store.Comment{
ID: "c-1",
ParentID: "id-1",
Text: "restricted word",
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
User: store.User{ID: "user2", Name: "user name 2"},
}
// Key call error
id, err := b.Create(comment)
assert.ErrorContainsf(t, err, "mock key err", "should fail with mock error")
assert.Empty(t, id)
assert.Equal(t, len(as.KeyCalls()), 1)
assert.Equal(t, len(as.EnabledCalls()), 0)
assert.Equal(t, len(as.OnEventCalls()), 0)
// Enabled call error
badKey = false
id, err = b.Create(comment)
assert.ErrorContains(t, err, "mock enabled err", "should fail with mock error")
assert.Empty(t, id)
assert.Equal(t, len(as.KeyCalls()), 2)
assert.Equal(t, len(as.EnabledCalls()), 1)
assert.Equal(t, len(as.OnEventCalls()), 0)
// only OnEvent error
badEnabled = false
id, err = b.Create(comment)
assert.NoError(t, err, "OnEvent error should be just logged")
assert.Equal(t, id, "c-1")
assert.Equal(t, len(as.KeyCalls()), 3)
assert.Equal(t, len(as.EnabledCalls()), 2)
assert.Equal(t, len(as.OnEventCalls()), 1)
// OnEvent error on Vote call
_, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "c-1",
UserID: "user4", Val: true})
assert.NoError(t, err, "OnEvent error should be just logged")
assert.Equal(t, len(as.KeyCalls()), 4)
assert.Equal(t, len(as.EnabledCalls()), 3)
assert.Equal(t, len(as.OnEventCalls()), 2)
// Admins error
isAdmin := b.IsAdmin("radio-t", "user2")
assert.False(t, isAdmin)
assert.Equal(t, len(as.AdminsCalls()), 1)
assert.Equal(t, len(as.OnEventCalls()), 2)
// OnEvent error on EditComment call
_, err = b.EditComment(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "c-1", EditRequest{Text: "new text"})
assert.NoError(t, err, "OnEvent error should be just logged")
// OnEvent error on Delete call
err = b.Delete(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "c-1", store.SoftDelete)
assert.NoError(t, err, "OnEvent error should be just logged")
// OnEvent error on EditComment Delete call
_, err = b.EditComment(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "c-1", EditRequest{Delete: true})
assert.NoError(t, err, "OnEvent error should be just logged")
}
func TestService_VoteSameIP(t *testing.T) { func TestService_VoteSameIP(t *testing.T) {
eng, teardown := prepStoreEngine(t) eng, teardown := prepStoreEngine(t)
defer teardown() defer teardown()
@@ -775,27 +924,31 @@ func TestService_EditCommentAdmin(t *testing.T) {
} }
func TestService_ValidateComment(t *testing.T) { func TestService_ValidateComment(t *testing.T) {
b := DataStore{MaxCommentSize: 2000, AdminStore: admin.NewStaticKeyStore("secret 123")} b := DataStore{MinCommentSize: 6, MaxCommentSize: 2000, AdminStore: admin.NewStaticKeyStore("secret 123")}
longText := fmt.Sprintf("%4000s", "X") longText := fmt.Sprintf("%4000s", "X")
tbl := []struct { tbl := []struct {
inp store.Comment inp store.Comment
err error err string
}{ }{
{inp: store.Comment{}, err: fmt.Errorf("empty comment text")}, {inp: store.Comment{}, err: "empty comment text"},
{inp: store.Comment{Orig: "something blah", User: store.User{ID: "myid", Name: "name"}}, err: nil}, {inp: store.Comment{Orig: "something blah", User: store.User{ID: "myid", Name: "name"}}, err: ""},
{inp: store.Comment{Orig: "something blah", User: store.User{ID: "myid"}}, err: fmt.Errorf("empty user info")}, {inp: store.Comment{Orig: "something blah", User: store.User{ID: "myid"}}, err: "empty user info"},
{inp: store.Comment{Orig: longText, User: store.User{ID: "myid", Name: "name"}}, err: fmt.Errorf("comment text exceeded max allowed size 2000 (4000)")}, {inp: store.Comment{Orig: "short", User: store.User{ID: "myid", Name: "name"}}, err: "comment text is smaller than min allowed size 6 (5)"},
{inp: store.Comment{Orig: longText, User: store.User{ID: "myid", Name: "name"}}, err: "comment text exceeded max allowed size 2000 (4000)"},
{inp: store.Comment{Orig: "here is a link with relative URL: [google.com](url)", User: store.User{ID: "myid", Name: "name"}}, err: "links should start with mailto:, http:// or https://"},
{inp: store.Comment{Orig: "here is a link with relative URL: [google.com](url)", User: store.User{ID: "myid", Name: "name"}}, err: "links should start with mailto:, http:// or https://"},
{inp: store.Comment{Orig: "multiple links, one is bad: [test](http://test) [test2](bad_url) [test3](https://test3)", User: store.User{ID: "myid", Name: "name"}}, err: "links should start with mailto:, http:// or https://"},
} }
for n, tt := range tbl { for n, tt := range tbl {
err := b.ValidateComment(&tt.inp) err := b.ValidateComment(&tt.inp)
if tt.err == nil { if tt.err == "" {
assert.NoError(t, err, "check #%d", n) assert.NoError(t, err, "check #%d", n)
continue continue
} }
require.Error(t, err) require.Error(t, err)
assert.EqualError(t, tt.err, err.Error(), "check #%d", n) assert.EqualError(t, err, tt.err, "check #%d", n)
} }
} }
@@ -1154,7 +1307,7 @@ func TestService_Find(t *testing.T) {
assert.InDelta(t, 0, res[1].Controversy, 0.01) assert.InDelta(t, 0, res[1].Controversy, 0.01)
// make sure title sanitized // make sure title sanitized
assert.Equal(t, "some title, &lt;a href=\"http://radio-t.com\" rel=\"nofollow\"&gt;link&lt;/a&gt;", res[0].PostTitle) assert.Equal(t, "some title, link", res[0].PostTitle)
} }
func TestService_FindSince(t *testing.T) { func TestService_FindSince(t *testing.T) {
@@ -1183,18 +1336,55 @@ func TestService_Info(t *testing.T) {
b := DataStore{Engine: eng, EditDuration: 100 * time.Millisecond, b := DataStore{Engine: eng, EditDuration: 100 * time.Millisecond,
AdminStore: admin.NewStaticStore("secret 123", nil, []string{"user2"}, "user@email.com")} AdminStore: admin.NewStaticStore("secret 123", nil, []string{"user2"}, "user@email.com")}
info, err := b.Info(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, 0) // add one more comment for another URL to test site-wide Info request
comment := store.Comment{
ID: "123456xyz",
Text: `some text, <a href="http://radio-t.com">link</a>`,
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com/another", SiteID: "radio-t"},
User: store.User{ID: "user2", Name: "user name"},
}
_, err := b.Create(comment)
require.NoError(t, err)
// get non-existing URL info
info, err := b.Info(store.Locator{URL: "bad", SiteID: "radio-t"}, 0)
assert.Error(t, err)
assert.Empty(t, info)
// get non-existing site info
info, err = b.Info(store.Locator{SiteID: "bad"}, 0)
assert.Error(t, err)
assert.Empty(t, info)
// test two initially created comments and store first comment FirstTS
info, err = b.Info(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, 0)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "https://radio-t.com", info.URL) assert.Equal(t, "https://radio-t.com", info.URL)
assert.Equal(t, 2, info.Count) assert.Equal(t, 2, info.Count)
assert.False(t, info.ReadOnly) assert.False(t, info.ReadOnly)
assert.True(t, info.LastTS.After(info.FirstTS)) assert.True(t, info.LastTS.After(info.FirstTS))
firstTS := info.FirstTS
time.Sleep(1 * time.Second) // make post RO in 1sec time.Sleep(1 * time.Second) // make post RO in 1sec
info, err = b.Info(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, 1) info, err = b.Info(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, 1)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "https://radio-t.com", info.URL) assert.Equal(t, "https://radio-t.com", info.URL)
assert.True(t, info.ReadOnly) assert.True(t, info.ReadOnly)
// get last created comment LastTS
info, err = b.Info(store.Locator{URL: "https://radio-t.com/another", SiteID: "radio-t"}, 0)
require.NoError(t, err)
lastTS := info.LastTS
// site-level request
info, err = b.Info(store.Locator{SiteID: "radio-t"}, 1)
require.NoError(t, err)
assert.Equal(t, 3, info.Count)
assert.Empty(t, info.URL, "site-level request should not set URL")
assert.False(t, info.ReadOnly, "site-level request should not set ReadOnly")
assert.Equal(t, firstTS, info.FirstTS, "site-level request should have FirstTS from the first post")
assert.Equal(t, lastTS, info.LastTS, "site-level request should have LastTS from the last post")
} }
func TestService_Delete(t *testing.T) { func TestService_Delete(t *testing.T) {
@@ -1216,6 +1406,80 @@ func TestService_Delete(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestService_deleteImagesOnCommentDelete(t *testing.T) {
lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc)
mockStore := image.StoreMock{
DeleteFunc: func(string) error { return nil },
CommitFunc: func(string) error { return nil },
ResetCleanupTimerFunc: func(string) error { return nil },
}
imgSvc := image.NewService(&mockStore,
image.ServiceParams{
EditDuration: 50 * time.Millisecond,
ImageAPI: "/images/dev/",
ProxyAPI: "/non_existent",
})
defer imgSvc.Close(context.TODO())
// two comments for https://radio-t.com
eng, teardown := prepStoreEngine(t)
defer teardown()
b := DataStore{Engine: eng, EditDuration: 50 * time.Millisecond,
AdminStore: admin.NewStaticKeyStore("secret 123"), ImageService: imgSvc}
c := store.Comment{
ID: "id-22",
Text: `some text <img src="/images/dev/pic1.png"/> xx <img src="/images/dev/pic2.png"/>`,
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
_, err := b.Engine.Create(c) // create directly with engine, doesn't call submitImages
assert.NoError(t, err)
b.submitImages(c)
// reply to the first comment with one new image and one existing one
c = store.Comment{
ID: "id-23",
ParentID: "id-22",
Text: `some text <img src="/images/dev/pic2.png"/> xx <img src="/images/dev/pic3.png"/>`,
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
_, err = b.Engine.Create(c) // create directly with engine, doesn't call submitImages
assert.NoError(t, err)
b.submitImages(c)
// verify that images are in staging store
assert.Equal(t, 4, len(mockStore.ResetCleanupTimerCalls()))
assert.Equal(t, "dev/pic1.png", mockStore.ResetCleanupTimerCalls()[0].ID)
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[1].ID)
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[2].ID)
assert.Equal(t, "dev/pic3.png", mockStore.ResetCleanupTimerCalls()[3].ID)
time.Sleep(b.EditDuration + 100*time.Millisecond)
// verify that they got into the main store
assert.Equal(t, 4, len(mockStore.CommitCalls()))
assert.Equal(t, "dev/pic1.png", mockStore.CommitCalls()[0].ID)
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[1].ID)
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[2].ID)
assert.Equal(t, "dev/pic3.png", mockStore.CommitCalls()[3].ID)
// delete the first comment
err = b.Delete(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-22", store.SoftDelete)
assert.NoError(t, err)
// verify that images are deleted from the main store
assert.Equal(t, 1, len(mockStore.DeleteCalls()))
assert.Equal(t, "dev/pic1.png", mockStore.DeleteCalls()[0].ID)
// delete the second comment
err = b.Delete(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-23", store.SoftDelete)
assert.NoError(t, err)
// verify that images are deleted from the main store
assert.Equal(t, 3, len(mockStore.DeleteCalls()))
assert.Equal(t, "dev/pic2.png", mockStore.DeleteCalls()[1].ID)
assert.Equal(t, "dev/pic3.png", mockStore.DeleteCalls()[2].ID)
}
// DeleteUser removes all comments from user // DeleteUser removes all comments from user
func TestService_DeleteUser(t *testing.T) { func TestService_DeleteUser(t *testing.T) {
// two comments for https://radio-t.com, no reply // two comments for https://radio-t.com, no reply
@@ -1396,8 +1660,8 @@ func TestService_submitImages(t *testing.T) {
lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc) lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc)
mockStore := image.StoreMock{ mockStore := image.StoreMock{
CommitFunc: func(id string) error { return nil }, CommitFunc: func(string) error { return nil },
ResetCleanupTimerFunc: func(id string) error { return nil }, ResetCleanupTimerFunc: func(string) error { return nil },
} }
imgSvc := image.NewService(&mockStore, imgSvc := image.NewService(&mockStore,
image.ServiceParams{ image.ServiceParams{
@@ -1438,10 +1702,10 @@ func TestService_ResubmitStagingImages(t *testing.T) {
InfoFunc: func() (image.StoreInfo, error) { InfoFunc: func() (image.StoreInfo, error) {
return image.StoreInfo{FirstStagingImageTS: time.Time{}.Add(time.Second)}, nil return image.StoreInfo{FirstStagingImageTS: time.Time{}.Add(time.Second)}, nil
}, },
CommitFunc: func(id string) error { CommitFunc: func(string) error {
return nil return nil
}, },
ResetCleanupTimerFunc: func(id string) error { return nil }, ResetCleanupTimerFunc: func(string) error { return nil },
} }
imgSvc := image.NewService(&mockStore, imgSvc := image.NewService(&mockStore,
image.ServiceParams{ image.ServiceParams{
@@ -1533,7 +1797,7 @@ func TestService_ResubmitStagingImages_EngineError(t *testing.T) {
first := true first := true
engineMock := engine.InterfaceMock{ engineMock := engine.InterfaceMock{
FindFunc: func(req engine.FindRequest) ([]store.Comment, error) { FindFunc: func(engine.FindRequest) ([]store.Comment, error) {
if first { if first {
first = false first = false
return nil, nil return nil, nil
@@ -1558,7 +1822,7 @@ func TestService_ResubmitStagingImages_EngineError(t *testing.T) {
func TestService_alterComment(t *testing.T) { func TestService_alterComment(t *testing.T) {
engineMock := engine.InterfaceMock{ engineMock := engine.InterfaceMock{
FlagFunc: func(req engine.FlagRequest) (bool, error) { FlagFunc: func(engine.FlagRequest) (bool, error) {
return false, nil return false, nil
}, },
} }
@@ -1580,7 +1844,7 @@ func TestService_alterComment(t *testing.T) {
first := true first := true
engineMock = engine.InterfaceMock{ engineMock = engine.InterfaceMock{
FlagFunc: func(req engine.FlagRequest) (bool, error) { FlagFunc: func(engine.FlagRequest) (bool, error) {
if first { if first {
first = false first = false
return false, nil return false, nil
@@ -1598,7 +1862,7 @@ func TestService_alterComment(t *testing.T) {
first = true first = true
engineMock = engine.InterfaceMock{ engineMock = engine.InterfaceMock{
FlagFunc: func(req engine.FlagRequest) (bool, error) { FlagFunc: func(engine.FlagRequest) (bool, error) {
if first { if first {
first = false first = false
return true, nil return true, nil
@@ -1638,6 +1902,30 @@ func Benchmark_ServiceCreate(b *testing.B) {
} }
} }
func TestService_DoubleClose_Bolt(t *testing.T) {
dbFile := fmt.Sprintf("%s/test-remark42-%d.db", os.TempDir(), rand.Intn(9999999999))
defer func() { _ = os.Remove(dbFile) }()
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: dbFile, SiteID: "radio-t"})
svc := DataStore{Engine: boltStore, EditDuration: 50 * time.Millisecond, AdminStore: admin.NewStaticKeyStore("secret 123")}
require.NoError(t, err)
assert.NoError(t, boltStore.Close())
assert.NoError(t, boltStore.Close(), "second call should not result in panic or errors")
assert.NoError(t, svc.Close())
assert.NoError(t, svc.Close(), "second call should not result in panic or errors")
}
func TestService_DoubleClose_Static(t *testing.T) {
ks := admin.NewStaticKeyStore("secret 123")
eng, teardown := prepStoreEngine(t)
defer teardown()
b := DataStore{Engine: eng, AdminStore: ks,
TitleExtractor: NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{})}
assert.NoError(t, b.Close())
// second call should not result in panic or errors
assert.NoError(t, b.Close())
}
// makes new boltdb, put two records // makes new boltdb, put two records
func prepStoreEngine(t *testing.T) (e engine.Interface, teardown func()) { func prepStoreEngine(t *testing.T) (e engine.Interface, teardown func()) {
testDBLoc, err := os.MkdirTemp("", "test_image_r42") testDBLoc, err := os.MkdirTemp("", "test_image_r42")
+2 -8
View File
@@ -242,11 +242,5 @@
"time": "2017-12-25T19:47:22Z" "time": "2017-12-25T19:47:22Z"
} }
} }
], ]
"info": { }
"url": "url",
"count": 12,
"first_time": "2017-12-25T19:46:01Z",
"last_time": "2017-12-25T19:47:22Z"
}
}
+2 -8
View File
@@ -176,11 +176,5 @@
} }
}] }]
}] }]
}], }]
"info": { }
"url": "url",
"count": 8,
"first_time": "2017-12-25T19:46:01Z",
"last_time": "2017-12-25T19:47:05Z"
}
}
+38 -16
View File
@@ -4,10 +4,11 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
"github.com/go-pkgz/lcw" "github.com/go-pkgz/lcw/v2"
log "github.com/go-pkgz/lgr" log "github.com/go-pkgz/lgr"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
@@ -19,32 +20,53 @@ const (
// TitleExtractor gets html title from remote page, cached // TitleExtractor gets html title from remote page, cached
type TitleExtractor struct { type TitleExtractor struct {
client http.Client client http.Client
cache lcw.LoadingCache cache lcw.LoadingCache[string]
allowedDomains []string
} }
// NewTitleExtractor makes extractor with cache. If memory cache failed, switching to no-cache // NewTitleExtractor makes extractor with cache. If memory cache failed, switching to no-cache
func NewTitleExtractor(client http.Client) *TitleExtractor { func NewTitleExtractor(client http.Client, allowedDomains []string) *TitleExtractor {
log.Printf("[DEBUG] creating extractor, allowed domains %+v", allowedDomains)
res := TitleExtractor{ res := TitleExtractor{
client: client, client: client,
allowedDomains: allowedDomains,
} }
var err error var err error
res.cache, err = lcw.NewExpirableCache(lcw.TTL(teCacheTTL), lcw.MaxKeySize(teCacheMaxRecs)) o := lcw.NewOpts[string]()
res.cache, err = lcw.NewExpirableCache(o.TTL(teCacheTTL), o.MaxKeySize(teCacheMaxRecs))
if err != nil { if err != nil {
log.Printf("[WARN] failed to make cache, caching disabled for titles, %v", err) log.Printf("[WARN] failed to make cache, caching disabled for titles, %v", err)
res.cache = &lcw.Nop{} res.cache = &lcw.Nop[string]{}
} }
return &res return &res
} }
// Get page for url and return title // Get page for url and return title
func (t *TitleExtractor) Get(url string) (string, error) { func (t *TitleExtractor) Get(pageURL string) (string, error) {
// parse domain of the URL and check if it's in the allowed list
u, err := url.Parse(pageURL)
if err != nil {
return "", fmt.Errorf("failed to parse url %s: %w", pageURL, err)
}
allowed := false
for _, domain := range t.allowedDomains {
if u.Hostname() == domain ||
(strings.HasSuffix(u.Hostname(), domain) && // suffix match, e.g. "example.com" matches "www.example.com"
u.Hostname()[len(u.Hostname())-len(domain)-1] == '.') { // but we should not match "notexample.com"
allowed = true
break
}
}
if !allowed {
return "", fmt.Errorf("domain %s is not allowed", u.Host)
}
client := http.Client{Timeout: t.client.Timeout, Transport: t.client.Transport} client := http.Client{Timeout: t.client.Timeout, Transport: t.client.Transport}
defer client.CloseIdleConnections() defer client.CloseIdleConnections()
b, err := t.cache.Get(url, func() (interface{}, error) { b, err := t.cache.Get(pageURL, func() (string, error) {
resp, err := client.Get(url) resp, e := client.Get(pageURL)
if err != nil { if e != nil {
return nil, fmt.Errorf("failed to load page %s: %w", url, err) return "", fmt.Errorf("failed to load page %s: %w", pageURL, e)
} }
defer func() { defer func() {
if err = resp.Body.Close(); err != nil { if err = resp.Body.Close(); err != nil {
@@ -52,23 +74,23 @@ func (t *TitleExtractor) Get(url string) (string, error) {
} }
}() }()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("can't load page %s, code %d", url, resp.StatusCode) return "", fmt.Errorf("can't load page %s, code %d", pageURL, resp.StatusCode)
} }
title, ok := t.getTitle(resp.Body) title, ok := t.getTitle(resp.Body)
if !ok { if !ok {
return nil, fmt.Errorf("can't get title for %s", url) return "", fmt.Errorf("can't get title for %s", pageURL)
} }
return title, nil return title, nil
}) })
// on error save result (empty string) to cache too and return "" title // on error save result (empty string) to cache too and return "" title
if err != nil { if err != nil {
_, _ = t.cache.Get(url, func() (interface{}, error) { return "", nil }) _, _ = t.cache.Get(pageURL, func() (string, error) { return "", nil })
return "", err return "", err
} }
return b.(string), nil return b, nil
} }
// Close title extractor // Close title extractor
+12 -5
View File
@@ -28,7 +28,7 @@ func TestTitle_GetTitle(t *testing.T) {
{`<html><body> 2222</body></html>`, false, ""}, {`<html><body> 2222</body></html>`, false, ""},
} }
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}) ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{})
defer ex.Close() defer ex.Close()
for i, tt := range tbl { for i, tt := range tbl {
tt := tt tt := tt
@@ -41,7 +41,7 @@ func TestTitle_GetTitle(t *testing.T) {
} }
func TestTitle_Get(t *testing.T) { func TestTitle_Get(t *testing.T) {
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}) ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
defer ex.Close() defer ex.Close()
var hits int32 var hits int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -75,7 +75,7 @@ func TestTitle_GetConcurrent(t *testing.T) {
for n := 0; n < 1000; n++ { for n := 0; n < 1000; n++ {
body += "something something blah blah\n" body += "something something blah blah\n"
} }
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}) ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
defer ex.Close() defer ex.Close()
var hits int32 var hits int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -104,10 +104,10 @@ func TestTitle_GetConcurrent(t *testing.T) {
} }
func TestTitle_GetFailed(t *testing.T) { func TestTitle_GetFailed(t *testing.T) {
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}) ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
defer ex.Close() defer ex.Close()
var hits int32 var hits int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1) atomic.AddInt32(&hits, 1)
w.WriteHeader(404) w.WriteHeader(404)
})) }))
@@ -123,3 +123,10 @@ func TestTitle_GetFailed(t *testing.T) {
} }
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "hit once, errors cached") assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "hit once, errors cached")
} }
func TestTitle_DoubleClosed(t *testing.T) {
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{})
assert.NoError(t, ex.Close())
// second call should not result in panic
assert.NoError(t, ex.Close())
}
+8 -29
View File
@@ -10,8 +10,7 @@ import (
// Tree is formatter making tree from the list of comments // Tree is formatter making tree from the list of comments
type Tree struct { type Tree struct {
Nodes []*Node `json:"comments"` Nodes []*Node `json:"comments"`
Info store.PostInfo `json:"info,omitempty"`
} }
// Node is a comment with optional replies // Node is a comment with optional replies
@@ -30,22 +29,12 @@ type recurData struct {
} }
// MakeTree gets unsorted list of comments and produces Tree // MakeTree gets unsorted list of comments and produces Tree
// It will make store.PostInfo by itself and will mark Info.ReadOnly based on passed readOnlyAge func MakeTree(comments []store.Comment, sortType string) *Tree {
// Tree maker is local and has no access to the data store. By this reason it has to make Info and won't be able
// to handle store's read-only status. This status should be set by caller.
func MakeTree(comments []store.Comment, sortType string, readOnlyAge int) *Tree {
if len(comments) == 0 { if len(comments) == 0 {
return &Tree{} return &Tree{}
} }
res := Tree{ res := Tree{}
Info: store.PostInfo{
URL: comments[0].Locator.URL,
FirstTS: comments[0].Timestamp,
LastTS: comments[0].Timestamp,
},
}
res.Info.Count = len(res.filter(comments, func(c store.Comment) bool { return !c.Deleted }))
topComments := res.filter(comments, func(c store.Comment) bool { return c.ParentID == "" }) topComments := res.filter(comments, func(c store.Comment) bool { return c.ParentID == "" })
@@ -54,23 +43,12 @@ func MakeTree(comments []store.Comment, sortType string, readOnlyAge int) *Tree
node := Node{Comment: rootComment} node := Node{Comment: rootComment}
rd := recurData{} rd := recurData{}
commentsTree, tsModified, tsCreated := res.proc(comments, &node, &rd, rootComment.ID) commentsTree := res.proc(comments, &node, &rd, rootComment.ID)
// skip deleted with no sub-comments ar all sub-comments deleted // skip deleted with no sub-comments and all sub-comments deleted
if rootComment.Deleted && (len(commentsTree.Replies) == 0 || !rd.visible) { if rootComment.Deleted && (len(commentsTree.Replies) == 0 || !rd.visible) {
continue continue
} }
commentsTree.tsModified, commentsTree.tsCreated = tsModified, tsCreated
if commentsTree.tsCreated.Before(res.Info.FirstTS) {
res.Info.FirstTS = commentsTree.tsCreated
}
if commentsTree.tsModified.After(res.Info.LastTS) {
res.Info.LastTS = commentsTree.tsModified
}
res.Info.ReadOnly = readOnlyAge > 0 && !res.Info.FirstTS.IsZero() &&
res.Info.FirstTS.AddDate(0, 0, readOnlyAge).Before(time.Now())
res.Nodes = append(res.Nodes, commentsTree) res.Nodes = append(res.Nodes, commentsTree)
} }
@@ -79,7 +57,7 @@ func MakeTree(comments []store.Comment, sortType string, readOnlyAge int) *Tree
} }
// proc makes tree for one top-level comment recursively // proc makes tree for one top-level comment recursively
func (t *Tree) proc(comments []store.Comment, node *Node, rd *recurData, parentID string) (result *Node, modified, created time.Time) { func (t *Tree) proc(comments []store.Comment, node *Node, rd *recurData, parentID string) (result *Node) {
if rd.tsModified.IsZero() || rd.tsCreated.IsZero() { if rd.tsModified.IsZero() || rd.tsCreated.IsZero() {
rd.tsModified, rd.tsCreated = node.Comment.Timestamp, node.Comment.Timestamp rd.tsModified, rd.tsCreated = node.Comment.Timestamp, node.Comment.Timestamp
} }
@@ -106,7 +84,8 @@ func (t *Tree) proc(comments []store.Comment, node *Node, rd *recurData, parentI
sort.Slice(node.Replies, func(i, j int) bool { sort.Slice(node.Replies, func(i, j int) bool {
return node.Replies[i].Comment.Timestamp.Before(node.Replies[j].Comment.Timestamp) return node.Replies[i].Comment.Timestamp.Before(node.Replies[j].Comment.Timestamp)
}) })
return node, rd.tsModified, rd.tsCreated node.tsModified, node.tsCreated = rd.tsModified, rd.tsCreated
return node
} }
// filter returns comments for parentID // filter returns comments for parentID
+14 -18
View File
@@ -37,19 +37,15 @@ func TestMakeTree(t *testing.T) {
{Locator: loc, ID: "611", ParentID: "61", Deleted: true}, {Locator: loc, ID: "611", ParentID: "61", Deleted: true},
} }
res := MakeTree(comments, "time", 0) res := MakeTree(comments, "time")
resJSON, err := json.Marshal(&res) resJSON, err := json.Marshal(&res)
require.NoError(t, err) require.NoError(t, err)
expJSON := mustLoadJSONFile(t, "testdata/tree.json") expJSON := mustLoadJSONFile(t, "testdata/tree.json")
assert.Equal(t, expJSON, resJSON) assert.Equal(t, expJSON, resJSON)
assert.Equal(t, store.PostInfo{URL: "url", Count: 12, FirstTS: ts(46, 1), LastTS: ts(47, 22)}, res.Info)
res = MakeTree([]store.Comment{}, "time", 0) res = MakeTree([]store.Comment{}, "time")
assert.Equal(t, &Tree{}, res) assert.Equal(t, &Tree{}, res)
res = MakeTree(comments, "time", 10)
assert.Equal(t, store.PostInfo{URL: "url", Count: 12, FirstTS: ts(46, 1), LastTS: ts(47, 22), ReadOnly: true}, res.Info)
} }
func TestMakeEmptySubtree(t *testing.T) { func TestMakeEmptySubtree(t *testing.T) {
@@ -79,7 +75,7 @@ func TestMakeEmptySubtree(t *testing.T) {
{Locator: loc, ID: "3", Timestamp: ts(48, 1), Deleted: true}, // deleted top level {Locator: loc, ID: "3", Timestamp: ts(48, 1), Deleted: true}, // deleted top level
} }
res := MakeTree(comments, "time", 0) res := MakeTree(comments, "time")
resJSON, err := json.Marshal(&res) resJSON, err := json.Marshal(&res)
require.NoError(t, err) require.NoError(t, err)
t.Log(string(resJSON)) t.Log(string(resJSON))
@@ -108,50 +104,50 @@ func TestTreeSortNodes(t *testing.T) {
{ID: "5", Deleted: true, Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 150, time.UTC)}, {ID: "5", Deleted: true, Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 150, time.UTC)},
} }
res := MakeTree(comments, "+active", 0) res := MakeTree(comments, "+active")
assert.Equal(t, "2", res.Nodes[0].Comment.ID) assert.Equal(t, "2", res.Nodes[0].Comment.ID)
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified) t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
res = MakeTree(comments, "-active", 0) res = MakeTree(comments, "-active")
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified) t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
assert.Equal(t, "1", res.Nodes[0].Comment.ID) assert.Equal(t, "1", res.Nodes[0].Comment.ID)
res = MakeTree(comments, "+time", 0) res = MakeTree(comments, "+time")
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified) t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
assert.Equal(t, "1", res.Nodes[0].Comment.ID) assert.Equal(t, "1", res.Nodes[0].Comment.ID)
res = MakeTree(comments, "-time", 0) res = MakeTree(comments, "-time")
assert.Equal(t, "6", res.Nodes[0].Comment.ID) assert.Equal(t, "6", res.Nodes[0].Comment.ID)
res = MakeTree(comments, "score", 0) res = MakeTree(comments, "score")
assert.Equal(t, "4", res.Nodes[0].Comment.ID) assert.Equal(t, "4", res.Nodes[0].Comment.ID)
assert.Equal(t, "3", res.Nodes[1].Comment.ID) assert.Equal(t, "3", res.Nodes[1].Comment.ID)
assert.Equal(t, "6", res.Nodes[2].Comment.ID) assert.Equal(t, "6", res.Nodes[2].Comment.ID)
assert.Equal(t, "1", res.Nodes[3].Comment.ID) assert.Equal(t, "1", res.Nodes[3].Comment.ID)
res = MakeTree(comments, "+score", 0) res = MakeTree(comments, "+score")
assert.Equal(t, "4", res.Nodes[0].Comment.ID) assert.Equal(t, "4", res.Nodes[0].Comment.ID)
res = MakeTree(comments, "-score", 0) res = MakeTree(comments, "-score")
assert.Equal(t, "2", res.Nodes[0].Comment.ID) assert.Equal(t, "2", res.Nodes[0].Comment.ID)
assert.Equal(t, "1", res.Nodes[1].Comment.ID) assert.Equal(t, "1", res.Nodes[1].Comment.ID)
assert.Equal(t, "3", res.Nodes[2].Comment.ID) assert.Equal(t, "3", res.Nodes[2].Comment.ID)
assert.Equal(t, "6", res.Nodes[3].Comment.ID) assert.Equal(t, "6", res.Nodes[3].Comment.ID)
res = MakeTree(comments, "+controversy", 0) res = MakeTree(comments, "+controversy")
assert.Equal(t, "3", res.Nodes[0].Comment.ID) assert.Equal(t, "3", res.Nodes[0].Comment.ID)
assert.Equal(t, "6", res.Nodes[1].Comment.ID) assert.Equal(t, "6", res.Nodes[1].Comment.ID)
assert.Equal(t, "2", res.Nodes[2].Comment.ID) assert.Equal(t, "2", res.Nodes[2].Comment.ID)
assert.Equal(t, "4", res.Nodes[3].Comment.ID) assert.Equal(t, "4", res.Nodes[3].Comment.ID)
assert.Equal(t, "1", res.Nodes[4].Comment.ID) assert.Equal(t, "1", res.Nodes[4].Comment.ID)
res = MakeTree(comments, "-controversy", 0) res = MakeTree(comments, "-controversy")
assert.Equal(t, "1", res.Nodes[0].Comment.ID) assert.Equal(t, "1", res.Nodes[0].Comment.ID)
assert.Equal(t, "4", res.Nodes[1].Comment.ID) assert.Equal(t, "4", res.Nodes[1].Comment.ID)
assert.Equal(t, "2", res.Nodes[2].Comment.ID) assert.Equal(t, "2", res.Nodes[2].Comment.ID)
assert.Equal(t, "3", res.Nodes[3].Comment.ID) assert.Equal(t, "3", res.Nodes[3].Comment.ID)
res = MakeTree(comments, "undefined", 0) res = MakeTree(comments, "undefined")
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified) t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
assert.Equal(t, "1", res.Nodes[0].Comment.ID) assert.Equal(t, "1", res.Nodes[0].Comment.ID)
} }
@@ -164,7 +160,7 @@ func BenchmarkTree(b *testing.B) {
assert.NoError(b, err) assert.NoError(b, err)
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
res := MakeTree(comments, "time", 0) res := MakeTree(comments, "time")
assert.NotNil(b, res) assert.NotNil(b, res)
} }
} }
+48 -52
View File
@@ -1,78 +1,74 @@
module github.com/umputun/remark42/backend module github.com/umputun/remark42/backend
go 1.17 go 1.21
require ( require (
github.com/Depado/bfchroma/v2 v2.0.0 github.com/Depado/bfchroma/v2 v2.0.0
github.com/PuerkitoBio/goquery v1.8.0 github.com/PuerkitoBio/goquery v1.9.2
github.com/alecthomas/chroma/v2 v2.3.0 github.com/alecthomas/chroma/v2 v2.13.0
github.com/didip/tollbooth/v7 v7.0.1 github.com/didip/tollbooth/v7 v7.0.2
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f
github.com/go-chi/chi/v5 v5.0.7 github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/go-chi/render v1.0.2 github.com/go-chi/render v1.0.3
github.com/go-pkgz/auth v1.20.0 github.com/go-pkgz/auth v1.24.2
github.com/go-pkgz/jrpc v0.3.0 github.com/go-pkgz/jrpc v0.3.0
github.com/go-pkgz/lcw v1.0.1 github.com/go-pkgz/lcw/v2 v2.0.0
github.com/go-pkgz/lgr v0.10.4 github.com/go-pkgz/lgr v0.11.1
github.com/go-pkgz/notify v0.2.0 github.com/go-pkgz/notify v1.1.1
github.com/go-pkgz/repeater v1.1.3 github.com/go-pkgz/repeater v1.1.3
github.com/go-pkgz/rest v1.16.0 github.com/go-pkgz/rest v1.19.0
github.com/go-pkgz/syncs v1.2.0 github.com/go-pkgz/syncs v1.3.2
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.3.0 github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.1.1 github.com/gorilla/feeds v1.2.0
github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-multierror v1.1.1
github.com/jessevdk/go-flags v1.5.0 github.com/jessevdk/go-flags v1.6.1
github.com/kyokomi/emoji/v2 v2.2.10 github.com/kyokomi/emoji/v2 v2.2.13
github.com/microcosm-cc/bluemonday v1.0.20 github.com/microcosm-cc/bluemonday v1.0.27
github.com/rs/xid v1.4.0 github.com/rs/xid v1.5.0
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.0 github.com/stretchr/testify v1.9.0
go.etcd.io/bbolt v1.3.6 go.etcd.io/bbolt v1.3.10
go.uber.org/goleak v1.2.0 go.uber.org/goleak v1.3.0
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d golang.org/x/crypto v0.25.0
golang.org/x/image v0.0.0-20220617043117-41969df76e82 golang.org/x/image v0.18.0
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b golang.org/x/net v0.27.0
) )
require ( require (
cloud.google.com/go/compute v1.6.1 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect
github.com/ajg/form v1.5.1 // indirect github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dghubble/oauth1 v0.7.1 // indirect github.com/dghubble/oauth1 v0.7.3 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/go-oauth2/oauth2/v4 v4.5.1 // indirect github.com/go-oauth2/oauth2/v4 v4.5.2 // indirect
github.com/go-pkgz/email v0.4.0 // indirect github.com/go-pkgz/email v0.5.0 // indirect
github.com/go-pkgz/expirable-cache v0.1.0 // indirect github.com/go-pkgz/expirable-cache/v3 v3.0.0 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.15.2 // indirect github.com/klauspost/compress v1.17.9 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/slack-go/slack v0.11.3 // indirect github.com/redis/go-redis/v9 v9.4.0 // indirect
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect
github.com/slack-go/slack v0.12.5 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.1 // indirect github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.3 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.10.2 // indirect go.mongodb.org/mongo-driver v1.16.0 // indirect
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.16.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+160 -675
View File
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -103,6 +103,14 @@ GET {{host}}/api/v1/admin/blocked?site={{site}}
### delete comment by id ### delete comment by id
DELETE {{host}}/api/v1/admin/comment/3665976683?site={{site}}&url={{url}} DELETE {{host}}/api/v1/admin/comment/3665976683?site={{site}}&url={{url}}
### export site (for backup)
GET {{host}}/api/v1/admin/export?site={{site}}&mode=stream
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
### export site (for backup) to .gz file
GET {{host}}/api/v1/admin/export?site={{site}}&mode=file
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
### get post info ### get post info
GET {{host}}/api/v1/info?site={{site}}&url={{url}} GET {{host}}/api/v1/info?site={{site}}&url={{url}}
@@ -119,12 +127,27 @@ GET {{host}}/api/v1/rss/reply?site={{site}}&user={{user}}
GET {{host}}/api/v1/avatar/blah GET {{host}}/api/v1/avatar/blah
### send confirmation token for current user to specified email. auth token for dev user for secret=12345. ### send confirmation token for current user to specified email. auth token for dev user for secret=12345.
POST {{host}}/api/v1/email/subscribe?site={{site}}&address={{email}} ### in case the user logged in with the same email, it will be confirmed right away with "updated" set to "true" in the response,
### and no email will be sent.
POST {{host}}/api/v1/email/subscribe
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
Content-Type: application/json
{
"site": "{{site}}",
"address": "{{email}}"
}
### add email for notifications for current user via token from email. auth token for dev user for secret=12345. ### add email for notifications for current user via token from email. auth token for dev user for secret=12345.
POST {{host}}/api/v1/email/confirm?site={{site}}&tkn={{token}} POST {{host}}/api/v1/email/confirm
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
Content-Type: application/json
{
"site": "{{site}}",
"token": "{{token}}"
}
### get current user email. auth token for dev user for secret=12345. ### get current user email. auth token for dev user for secret=12345.
GET {{host}}/api/v1/email?site={{site}} GET {{host}}/api/v1/email?site={{site}}
+45
View File
@@ -0,0 +1,45 @@
# Changes
## [0.5.0](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.4.0...compute/metadata/v0.5.0) (2024-07-10)
### Features
* **compute/metadata:** Add sys check for windows OnGCE ([#10521](https://github.com/googleapis/google-cloud-go/issues/10521)) ([3b9a830](https://github.com/googleapis/google-cloud-go/commit/3b9a83063960d2a2ac20beb47cc15818a68bd302))
## [0.4.0](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.3.0...compute/metadata/v0.4.0) (2024-07-01)
### Features
* **compute/metadata:** Add context for all functions/methods ([#10370](https://github.com/googleapis/google-cloud-go/issues/10370)) ([66b8efe](https://github.com/googleapis/google-cloud-go/commit/66b8efe7ad877e052b2987bb4475477e38c67bb3))
### Documentation
* **compute/metadata:** Update OnGCE description ([#10408](https://github.com/googleapis/google-cloud-go/issues/10408)) ([6a46dca](https://github.com/googleapis/google-cloud-go/commit/6a46dca4eae4f88ec6f88822e01e5bf8aeca787f))
## [0.3.0](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.2.3...compute/metadata/v0.3.0) (2024-04-15)
### Features
* **compute/metadata:** Add context aware functions ([#9733](https://github.com/googleapis/google-cloud-go/issues/9733)) ([e4eb5b4](https://github.com/googleapis/google-cloud-go/commit/e4eb5b46ee2aec9d2fc18300bfd66015e25a0510))
## [0.2.3](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.2.2...compute/metadata/v0.2.3) (2022-12-15)
### Bug Fixes
* **compute/metadata:** Switch DNS lookup to an absolute lookup ([119b410](https://github.com/googleapis/google-cloud-go/commit/119b41060c7895e45e48aee5621ad35607c4d021)), refs [#7165](https://github.com/googleapis/google-cloud-go/issues/7165)
## [0.2.2](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.2.1...compute/metadata/v0.2.2) (2022-12-01)
### Bug Fixes
* **compute/metadata:** Set IdleConnTimeout for http.Client ([#7084](https://github.com/googleapis/google-cloud-go/issues/7084)) ([766516a](https://github.com/googleapis/google-cloud-go/commit/766516aaf3816bfb3159efeea65aa3d1d205a3e2)), refs [#5430](https://github.com/googleapis/google-cloud-go/issues/5430)
## [0.1.0] (2022-10-26)
Initial release of metadata being it's own module.
+27
View File
@@ -0,0 +1,27 @@
# Compute API
[![Go Reference](https://pkg.go.dev/badge/cloud.google.com/go/compute.svg)](https://pkg.go.dev/cloud.google.com/go/compute/metadata)
This is a utility library for communicating with Google Cloud metadata service
on Google Cloud.
## Install
```bash
go get cloud.google.com/go/compute/metadata
```
## Go Version Support
See the [Go Versions Supported](https://github.com/googleapis/google-cloud-go#go-versions-supported)
section in the root directory's README.
## Contributing
Contributions are welcome. Please, see the [CONTRIBUTING](https://github.com/GoogleCloudPlatform/google-cloud-go/blob/main/CONTRIBUTING.md)
document for details.
Please note that this project is released with a Contributor Code of Conduct.
By participating in this project you agree to abide by its terms. See
[Contributor Code of Conduct](https://github.com/GoogleCloudPlatform/google-cloud-go/blob/main/CONTRIBUTING.md#contributor-code-of-conduct)
for more information.
+380 -85
View File
@@ -16,19 +16,18 @@
// metadata and API service accounts. // metadata and API service accounts.
// //
// This package is a wrapper around the GCE metadata service, // This package is a wrapper around the GCE metadata service,
// as documented at https://developers.google.com/compute/docs/metadata. // as documented at https://cloud.google.com/compute/docs/metadata/overview.
package metadata // import "cloud.google.com/go/compute/metadata" package metadata // import "cloud.google.com/go/compute/metadata"
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"runtime"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -70,7 +69,9 @@ func newDefaultHTTPClient() *http.Client {
Timeout: 2 * time.Second, Timeout: 2 * time.Second,
KeepAlive: 30 * time.Second, KeepAlive: 30 * time.Second,
}).Dial, }).Dial,
IdleConnTimeout: 60 * time.Second,
}, },
Timeout: 5 * time.Second,
} }
} }
@@ -86,16 +87,16 @@ func (suffix NotDefinedError) Error() string {
return fmt.Sprintf("metadata: GCE metadata %q not defined", string(suffix)) return fmt.Sprintf("metadata: GCE metadata %q not defined", string(suffix))
} }
func (c *cachedValue) get(cl *Client) (v string, err error) { func (c *cachedValue) get(ctx context.Context, cl *Client) (v string, err error) {
defer c.mu.Unlock() defer c.mu.Unlock()
c.mu.Lock() c.mu.Lock()
if c.v != "" { if c.v != "" {
return c.v, nil return c.v, nil
} }
if c.trim { if c.trim {
v, err = cl.getTrimmed(c.k) v, err = cl.getTrimmed(ctx, c.k)
} else { } else {
v, err = cl.Get(c.k) v, err = cl.GetWithContext(ctx, c.k)
} }
if err == nil { if err == nil {
c.v = v c.v = v
@@ -108,7 +109,9 @@ var (
onGCE bool onGCE bool
) )
// OnGCE reports whether this process is running on Google Compute Engine. // OnGCE reports whether this process is running on Google Compute Platforms.
// NOTE: True returned from `OnGCE` does not guarantee that the metadata server
// is accessible from this process and have all the metadata defined.
func OnGCE() bool { func OnGCE() bool {
onGCEOnce.Do(initOnGCE) onGCEOnce.Do(initOnGCE)
return onGCE return onGCE
@@ -145,7 +148,7 @@ func testOnGCE() bool {
go func() { go func() {
resolver := &net.Resolver{} resolver := &net.Resolver{}
addrs, err := resolver.LookupHost(ctx, "metadata.google.internal") addrs, err := resolver.LookupHost(ctx, "metadata.google.internal.")
if err != nil || len(addrs) == 0 { if err != nil || len(addrs) == 0 {
resc <- false resc <- false
return return
@@ -186,78 +189,213 @@ func testOnGCE() bool {
return <-resc return <-resc
} }
// systemInfoSuggestsGCE reports whether the local system (without // Subscribe calls Client.SubscribeWithContext on the default client.
// doing network requests) suggests that we're running on GCE. If this //
// returns true, testOnGCE tries a bit harder to reach its metadata // Deprecated: Please use the context aware variant [SubscribeWithContext].
// server.
func systemInfoSuggestsGCE() bool {
if runtime.GOOS != "linux" {
// We don't have any non-Linux clues available, at least yet.
return false
}
slurp, _ := ioutil.ReadFile("/sys/class/dmi/id/product_name")
name := strings.TrimSpace(string(slurp))
return name == "Google" || name == "Google Compute Engine"
}
// Subscribe calls Client.Subscribe on the default client.
func Subscribe(suffix string, fn func(v string, ok bool) error) error { func Subscribe(suffix string, fn func(v string, ok bool) error) error {
return defaultClient.Subscribe(suffix, fn) return defaultClient.SubscribeWithContext(context.Background(), suffix, func(ctx context.Context, v string, ok bool) error { return fn(v, ok) })
} }
// Get calls Client.Get on the default client. // SubscribeWithContext calls Client.SubscribeWithContext on the default client.
func Get(suffix string) (string, error) { return defaultClient.Get(suffix) } func SubscribeWithContext(ctx context.Context, suffix string, fn func(ctx context.Context, v string, ok bool) error) error {
return defaultClient.SubscribeWithContext(ctx, suffix, fn)
}
// Get calls Client.GetWithContext on the default client.
//
// Deprecated: Please use the context aware variant [GetWithContext].
func Get(suffix string) (string, error) {
return defaultClient.GetWithContext(context.Background(), suffix)
}
// GetWithContext calls Client.GetWithContext on the default client.
func GetWithContext(ctx context.Context, suffix string) (string, error) {
return defaultClient.GetWithContext(ctx, suffix)
}
// ProjectID returns the current instance's project ID string. // ProjectID returns the current instance's project ID string.
func ProjectID() (string, error) { return defaultClient.ProjectID() } //
// Deprecated: Please use the context aware variant [ProjectIDWithContext].
func ProjectID() (string, error) {
return defaultClient.ProjectIDWithContext(context.Background())
}
// ProjectIDWithContext returns the current instance's project ID string.
func ProjectIDWithContext(ctx context.Context) (string, error) {
return defaultClient.ProjectIDWithContext(ctx)
}
// NumericProjectID returns the current instance's numeric project ID. // NumericProjectID returns the current instance's numeric project ID.
func NumericProjectID() (string, error) { return defaultClient.NumericProjectID() } //
// Deprecated: Please use the context aware variant [NumericProjectIDWithContext].
func NumericProjectID() (string, error) {
return defaultClient.NumericProjectIDWithContext(context.Background())
}
// NumericProjectIDWithContext returns the current instance's numeric project ID.
func NumericProjectIDWithContext(ctx context.Context) (string, error) {
return defaultClient.NumericProjectIDWithContext(ctx)
}
// InternalIP returns the instance's primary internal IP address. // InternalIP returns the instance's primary internal IP address.
func InternalIP() (string, error) { return defaultClient.InternalIP() } //
// Deprecated: Please use the context aware variant [InternalIPWithContext].
func InternalIP() (string, error) {
return defaultClient.InternalIPWithContext(context.Background())
}
// InternalIPWithContext returns the instance's primary internal IP address.
func InternalIPWithContext(ctx context.Context) (string, error) {
return defaultClient.InternalIPWithContext(ctx)
}
// ExternalIP returns the instance's primary external (public) IP address. // ExternalIP returns the instance's primary external (public) IP address.
func ExternalIP() (string, error) { return defaultClient.ExternalIP() } //
// Deprecated: Please use the context aware variant [ExternalIPWithContext].
func ExternalIP() (string, error) {
return defaultClient.ExternalIPWithContext(context.Background())
}
// Email calls Client.Email on the default client. // ExternalIPWithContext returns the instance's primary external (public) IP address.
func Email(serviceAccount string) (string, error) { return defaultClient.Email(serviceAccount) } func ExternalIPWithContext(ctx context.Context) (string, error) {
return defaultClient.ExternalIPWithContext(ctx)
}
// Email calls Client.EmailWithContext on the default client.
//
// Deprecated: Please use the context aware variant [EmailWithContext].
func Email(serviceAccount string) (string, error) {
return defaultClient.EmailWithContext(context.Background(), serviceAccount)
}
// EmailWithContext calls Client.EmailWithContext on the default client.
func EmailWithContext(ctx context.Context, serviceAccount string) (string, error) {
return defaultClient.EmailWithContext(ctx, serviceAccount)
}
// Hostname returns the instance's hostname. This will be of the form // Hostname returns the instance's hostname. This will be of the form
// "<instanceID>.c.<projID>.internal". // "<instanceID>.c.<projID>.internal".
func Hostname() (string, error) { return defaultClient.Hostname() } //
// Deprecated: Please use the context aware variant [HostnameWithContext].
func Hostname() (string, error) {
return defaultClient.HostnameWithContext(context.Background())
}
// HostnameWithContext returns the instance's hostname. This will be of the form
// "<instanceID>.c.<projID>.internal".
func HostnameWithContext(ctx context.Context) (string, error) {
return defaultClient.HostnameWithContext(ctx)
}
// InstanceTags returns the list of user-defined instance tags, // InstanceTags returns the list of user-defined instance tags,
// assigned when initially creating a GCE instance. // assigned when initially creating a GCE instance.
func InstanceTags() ([]string, error) { return defaultClient.InstanceTags() } //
// Deprecated: Please use the context aware variant [InstanceTagsWithContext].
func InstanceTags() ([]string, error) {
return defaultClient.InstanceTagsWithContext(context.Background())
}
// InstanceTagsWithContext returns the list of user-defined instance tags,
// assigned when initially creating a GCE instance.
func InstanceTagsWithContext(ctx context.Context) ([]string, error) {
return defaultClient.InstanceTagsWithContext(ctx)
}
// InstanceID returns the current VM's numeric instance ID. // InstanceID returns the current VM's numeric instance ID.
func InstanceID() (string, error) { return defaultClient.InstanceID() } //
// Deprecated: Please use the context aware variant [InstanceIDWithContext].
func InstanceID() (string, error) {
return defaultClient.InstanceIDWithContext(context.Background())
}
// InstanceIDWithContext returns the current VM's numeric instance ID.
func InstanceIDWithContext(ctx context.Context) (string, error) {
return defaultClient.InstanceIDWithContext(ctx)
}
// InstanceName returns the current VM's instance ID string. // InstanceName returns the current VM's instance ID string.
func InstanceName() (string, error) { return defaultClient.InstanceName() } //
// Deprecated: Please use the context aware variant [InstanceNameWithContext].
func InstanceName() (string, error) {
return defaultClient.InstanceNameWithContext(context.Background())
}
// InstanceNameWithContext returns the current VM's instance ID string.
func InstanceNameWithContext(ctx context.Context) (string, error) {
return defaultClient.InstanceNameWithContext(ctx)
}
// Zone returns the current VM's zone, such as "us-central1-b". // Zone returns the current VM's zone, such as "us-central1-b".
func Zone() (string, error) { return defaultClient.Zone() } //
// Deprecated: Please use the context aware variant [ZoneWithContext].
func Zone() (string, error) {
return defaultClient.ZoneWithContext(context.Background())
}
// InstanceAttributes calls Client.InstanceAttributes on the default client. // ZoneWithContext returns the current VM's zone, such as "us-central1-b".
func InstanceAttributes() ([]string, error) { return defaultClient.InstanceAttributes() } func ZoneWithContext(ctx context.Context) (string, error) {
return defaultClient.ZoneWithContext(ctx)
}
// ProjectAttributes calls Client.ProjectAttributes on the default client. // InstanceAttributes calls Client.InstanceAttributesWithContext on the default client.
func ProjectAttributes() ([]string, error) { return defaultClient.ProjectAttributes() } //
// Deprecated: Please use the context aware variant [InstanceAttributesWithContext.
func InstanceAttributes() ([]string, error) {
return defaultClient.InstanceAttributesWithContext(context.Background())
}
// InstanceAttributeValue calls Client.InstanceAttributeValue on the default client. // InstanceAttributesWithContext calls Client.ProjectAttributesWithContext on the default client.
func InstanceAttributesWithContext(ctx context.Context) ([]string, error) {
return defaultClient.InstanceAttributesWithContext(ctx)
}
// ProjectAttributes calls Client.ProjectAttributesWithContext on the default client.
//
// Deprecated: Please use the context aware variant [ProjectAttributesWithContext].
func ProjectAttributes() ([]string, error) {
return defaultClient.ProjectAttributesWithContext(context.Background())
}
// ProjectAttributesWithContext calls Client.ProjectAttributesWithContext on the default client.
func ProjectAttributesWithContext(ctx context.Context) ([]string, error) {
return defaultClient.ProjectAttributesWithContext(ctx)
}
// InstanceAttributeValue calls Client.InstanceAttributeValueWithContext on the default client.
//
// Deprecated: Please use the context aware variant [InstanceAttributeValueWithContext].
func InstanceAttributeValue(attr string) (string, error) { func InstanceAttributeValue(attr string) (string, error) {
return defaultClient.InstanceAttributeValue(attr) return defaultClient.InstanceAttributeValueWithContext(context.Background(), attr)
} }
// ProjectAttributeValue calls Client.ProjectAttributeValue on the default client. // InstanceAttributeValueWithContext calls Client.InstanceAttributeValueWithContext on the default client.
func InstanceAttributeValueWithContext(ctx context.Context, attr string) (string, error) {
return defaultClient.InstanceAttributeValueWithContext(ctx, attr)
}
// ProjectAttributeValue calls Client.ProjectAttributeValueWithContext on the default client.
//
// Deprecated: Please use the context aware variant [ProjectAttributeValueWithContext].
func ProjectAttributeValue(attr string) (string, error) { func ProjectAttributeValue(attr string) (string, error) {
return defaultClient.ProjectAttributeValue(attr) return defaultClient.ProjectAttributeValueWithContext(context.Background(), attr)
} }
// Scopes calls Client.Scopes on the default client. // ProjectAttributeValueWithContext calls Client.ProjectAttributeValueWithContext on the default client.
func Scopes(serviceAccount string) ([]string, error) { return defaultClient.Scopes(serviceAccount) } func ProjectAttributeValueWithContext(ctx context.Context, attr string) (string, error) {
return defaultClient.ProjectAttributeValueWithContext(ctx, attr)
}
// Scopes calls Client.ScopesWithContext on the default client.
//
// Deprecated: Please use the context aware variant [ScopesWithContext].
func Scopes(serviceAccount string) ([]string, error) {
return defaultClient.ScopesWithContext(context.Background(), serviceAccount)
}
// ScopesWithContext calls Client.ScopesWithContext on the default client.
func ScopesWithContext(ctx context.Context, serviceAccount string) ([]string, error) {
return defaultClient.ScopesWithContext(ctx, serviceAccount)
}
func strsContains(ss []string, s string) bool { func strsContains(ss []string, s string) bool {
for _, v := range ss { for _, v := range ss {
@@ -280,14 +418,12 @@ func NewClient(c *http.Client) *Client {
if c == nil { if c == nil {
return defaultClient return defaultClient
} }
return &Client{hc: c} return &Client{hc: c}
} }
// getETag returns a value from the metadata service as well as the associated ETag. // getETag returns a value from the metadata service as well as the associated ETag.
// This func is otherwise equivalent to Get. // This func is otherwise equivalent to Get.
func (c *Client) getETag(suffix string) (value, etag string, err error) { func (c *Client) getETag(ctx context.Context, suffix string) (value, etag string, err error) {
ctx := context.TODO()
// Using a fixed IP makes it very difficult to spoof the metadata service in // Using a fixed IP makes it very difficult to spoof the metadata service in
// a container, which is an important use-case for local testing of cloud // a container, which is an important use-case for local testing of cloud
// deployments. To enable spoofing of the metadata service, the environment // deployments. To enable spoofing of the metadata service, the environment
@@ -304,7 +440,7 @@ func (c *Client) getETag(suffix string) (value, etag string, err error) {
} }
suffix = strings.TrimLeft(suffix, "/") suffix = strings.TrimLeft(suffix, "/")
u := "http://" + host + "/computeMetadata/v1/" + suffix u := "http://" + host + "/computeMetadata/v1/" + suffix
req, err := http.NewRequest("GET", u, nil) req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -334,7 +470,7 @@ func (c *Client) getETag(suffix string) (value, etag string, err error) {
if res.StatusCode == http.StatusNotFound { if res.StatusCode == http.StatusNotFound {
return "", "", NotDefinedError(suffix) return "", "", NotDefinedError(suffix)
} }
all, err := ioutil.ReadAll(res.Body) all, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -352,19 +488,37 @@ func (c *Client) getETag(suffix string) (value, etag string, err error) {
// //
// If the requested metadata is not defined, the returned error will // If the requested metadata is not defined, the returned error will
// be of type NotDefinedError. // be of type NotDefinedError.
//
// Deprecated: Please use the context aware variant [Client.GetWithContext].
func (c *Client) Get(suffix string) (string, error) { func (c *Client) Get(suffix string) (string, error) {
val, _, err := c.getETag(suffix) return c.GetWithContext(context.Background(), suffix)
}
// GetWithContext returns a value from the metadata service.
// The suffix is appended to "http://${GCE_METADATA_HOST}/computeMetadata/v1/".
//
// If the GCE_METADATA_HOST environment variable is not defined, a default of
// 169.254.169.254 will be used instead.
//
// If the requested metadata is not defined, the returned error will
// be of type NotDefinedError.
//
// NOTE: Without an extra deadline in the context this call can take in the
// worst case, with internal backoff retries, up to 15 seconds (e.g. when server
// is responding slowly). Pass context with additional timeouts when needed.
func (c *Client) GetWithContext(ctx context.Context, suffix string) (string, error) {
val, _, err := c.getETag(ctx, suffix)
return val, err return val, err
} }
func (c *Client) getTrimmed(suffix string) (s string, err error) { func (c *Client) getTrimmed(ctx context.Context, suffix string) (s string, err error) {
s, err = c.Get(suffix) s, err = c.GetWithContext(ctx, suffix)
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
return return
} }
func (c *Client) lines(suffix string) ([]string, error) { func (c *Client) lines(ctx context.Context, suffix string) ([]string, error) {
j, err := c.Get(suffix) j, err := c.GetWithContext(ctx, suffix)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -376,45 +530,104 @@ func (c *Client) lines(suffix string) ([]string, error) {
} }
// ProjectID returns the current instance's project ID string. // ProjectID returns the current instance's project ID string.
func (c *Client) ProjectID() (string, error) { return projID.get(c) } //
// Deprecated: Please use the context aware variant [Client.ProjectIDWithContext].
func (c *Client) ProjectID() (string, error) { return c.ProjectIDWithContext(context.Background()) }
// ProjectIDWithContext returns the current instance's project ID string.
func (c *Client) ProjectIDWithContext(ctx context.Context) (string, error) { return projID.get(ctx, c) }
// NumericProjectID returns the current instance's numeric project ID. // NumericProjectID returns the current instance's numeric project ID.
func (c *Client) NumericProjectID() (string, error) { return projNum.get(c) } //
// Deprecated: Please use the context aware variant [Client.NumericProjectIDWithContext].
func (c *Client) NumericProjectID() (string, error) {
return c.NumericProjectIDWithContext(context.Background())
}
// NumericProjectIDWithContext returns the current instance's numeric project ID.
func (c *Client) NumericProjectIDWithContext(ctx context.Context) (string, error) {
return projNum.get(ctx, c)
}
// InstanceID returns the current VM's numeric instance ID. // InstanceID returns the current VM's numeric instance ID.
func (c *Client) InstanceID() (string, error) { return instID.get(c) } //
// Deprecated: Please use the context aware variant [Client.InstanceIDWithContext].
func (c *Client) InstanceID() (string, error) {
return c.InstanceIDWithContext(context.Background())
}
// InstanceIDWithContext returns the current VM's numeric instance ID.
func (c *Client) InstanceIDWithContext(ctx context.Context) (string, error) {
return instID.get(ctx, c)
}
// InternalIP returns the instance's primary internal IP address. // InternalIP returns the instance's primary internal IP address.
//
// Deprecated: Please use the context aware variant [Client.InternalIPWithContext].
func (c *Client) InternalIP() (string, error) { func (c *Client) InternalIP() (string, error) {
return c.getTrimmed("instance/network-interfaces/0/ip") return c.InternalIPWithContext(context.Background())
}
// InternalIPWithContext returns the instance's primary internal IP address.
func (c *Client) InternalIPWithContext(ctx context.Context) (string, error) {
return c.getTrimmed(ctx, "instance/network-interfaces/0/ip")
} }
// Email returns the email address associated with the service account. // Email returns the email address associated with the service account.
// The account may be empty or the string "default" to use the instance's //
// main account. // Deprecated: Please use the context aware variant [Client.EmailWithContext].
func (c *Client) Email(serviceAccount string) (string, error) { func (c *Client) Email(serviceAccount string) (string, error) {
return c.EmailWithContext(context.Background(), serviceAccount)
}
// EmailWithContext returns the email address associated with the service account.
// The serviceAccount parameter default value (empty string or "default" value)
// will use the instance's main account.
func (c *Client) EmailWithContext(ctx context.Context, serviceAccount string) (string, error) {
if serviceAccount == "" { if serviceAccount == "" {
serviceAccount = "default" serviceAccount = "default"
} }
return c.getTrimmed("instance/service-accounts/" + serviceAccount + "/email") return c.getTrimmed(ctx, "instance/service-accounts/"+serviceAccount+"/email")
} }
// ExternalIP returns the instance's primary external (public) IP address. // ExternalIP returns the instance's primary external (public) IP address.
//
// Deprecated: Please use the context aware variant [Client.ExternalIPWithContext].
func (c *Client) ExternalIP() (string, error) { func (c *Client) ExternalIP() (string, error) {
return c.getTrimmed("instance/network-interfaces/0/access-configs/0/external-ip") return c.ExternalIPWithContext(context.Background())
}
// ExternalIPWithContext returns the instance's primary external (public) IP address.
func (c *Client) ExternalIPWithContext(ctx context.Context) (string, error) {
return c.getTrimmed(ctx, "instance/network-interfaces/0/access-configs/0/external-ip")
} }
// Hostname returns the instance's hostname. This will be of the form // Hostname returns the instance's hostname. This will be of the form
// "<instanceID>.c.<projID>.internal". // "<instanceID>.c.<projID>.internal".
//
// Deprecated: Please use the context aware variant [Client.HostnameWithContext].
func (c *Client) Hostname() (string, error) { func (c *Client) Hostname() (string, error) {
return c.getTrimmed("instance/hostname") return c.HostnameWithContext(context.Background())
} }
// InstanceTags returns the list of user-defined instance tags, // HostnameWithContext returns the instance's hostname. This will be of the form
// assigned when initially creating a GCE instance. // "<instanceID>.c.<projID>.internal".
func (c *Client) HostnameWithContext(ctx context.Context) (string, error) {
return c.getTrimmed(ctx, "instance/hostname")
}
// InstanceTags returns the list of user-defined instance tags.
//
// Deprecated: Please use the context aware variant [Client.InstanceTagsWithContext].
func (c *Client) InstanceTags() ([]string, error) { func (c *Client) InstanceTags() ([]string, error) {
return c.InstanceTagsWithContext(context.Background())
}
// InstanceTagsWithContext returns the list of user-defined instance tags,
// assigned when initially creating a GCE instance.
func (c *Client) InstanceTagsWithContext(ctx context.Context) ([]string, error) {
var s []string var s []string
j, err := c.Get("instance/tags") j, err := c.GetWithContext(ctx, "instance/tags")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -425,13 +638,27 @@ func (c *Client) InstanceTags() ([]string, error) {
} }
// InstanceName returns the current VM's instance ID string. // InstanceName returns the current VM's instance ID string.
//
// Deprecated: Please use the context aware variant [Client.InstanceNameWithContext].
func (c *Client) InstanceName() (string, error) { func (c *Client) InstanceName() (string, error) {
return c.getTrimmed("instance/name") return c.InstanceNameWithContext(context.Background())
}
// InstanceNameWithContext returns the current VM's instance ID string.
func (c *Client) InstanceNameWithContext(ctx context.Context) (string, error) {
return c.getTrimmed(ctx, "instance/name")
} }
// Zone returns the current VM's zone, such as "us-central1-b". // Zone returns the current VM's zone, such as "us-central1-b".
//
// Deprecated: Please use the context aware variant [Client.ZoneWithContext].
func (c *Client) Zone() (string, error) { func (c *Client) Zone() (string, error) {
zone, err := c.getTrimmed("instance/zone") return c.ZoneWithContext(context.Background())
}
// ZoneWithContext returns the current VM's zone, such as "us-central1-b".
func (c *Client) ZoneWithContext(ctx context.Context) (string, error) {
zone, err := c.getTrimmed(ctx, "instance/zone")
// zone is of the form "projects/<projNum>/zones/<zoneName>". // zone is of the form "projects/<projNum>/zones/<zoneName>".
if err != nil { if err != nil {
return "", err return "", err
@@ -442,12 +669,34 @@ func (c *Client) Zone() (string, error) {
// InstanceAttributes returns the list of user-defined attributes, // InstanceAttributes returns the list of user-defined attributes,
// assigned when initially creating a GCE VM instance. The value of an // assigned when initially creating a GCE VM instance. The value of an
// attribute can be obtained with InstanceAttributeValue. // attribute can be obtained with InstanceAttributeValue.
func (c *Client) InstanceAttributes() ([]string, error) { return c.lines("instance/attributes/") } //
// Deprecated: Please use the context aware variant [Client.InstanceAttributesWithContext].
func (c *Client) InstanceAttributes() ([]string, error) {
return c.InstanceAttributesWithContext(context.Background())
}
// InstanceAttributesWithContext returns the list of user-defined attributes,
// assigned when initially creating a GCE VM instance. The value of an
// attribute can be obtained with InstanceAttributeValue.
func (c *Client) InstanceAttributesWithContext(ctx context.Context) ([]string, error) {
return c.lines(ctx, "instance/attributes/")
}
// ProjectAttributes returns the list of user-defined attributes // ProjectAttributes returns the list of user-defined attributes
// applying to the project as a whole, not just this VM. The value of // applying to the project as a whole, not just this VM. The value of
// an attribute can be obtained with ProjectAttributeValue. // an attribute can be obtained with ProjectAttributeValue.
func (c *Client) ProjectAttributes() ([]string, error) { return c.lines("project/attributes/") } //
// Deprecated: Please use the context aware variant [Client.ProjectAttributesWithContext].
func (c *Client) ProjectAttributes() ([]string, error) {
return c.ProjectAttributesWithContext(context.Background())
}
// ProjectAttributesWithContext returns the list of user-defined attributes
// applying to the project as a whole, not just this VM. The value of
// an attribute can be obtained with ProjectAttributeValue.
func (c *Client) ProjectAttributesWithContext(ctx context.Context) ([]string, error) {
return c.lines(ctx, "project/attributes/")
}
// InstanceAttributeValue returns the value of the provided VM // InstanceAttributeValue returns the value of the provided VM
// instance attribute. // instance attribute.
@@ -457,8 +706,22 @@ func (c *Client) ProjectAttributes() ([]string, error) { return c.lines("project
// //
// InstanceAttributeValue may return ("", nil) if the attribute was // InstanceAttributeValue may return ("", nil) if the attribute was
// defined to be the empty string. // defined to be the empty string.
//
// Deprecated: Please use the context aware variant [Client.InstanceAttributeValueWithContext].
func (c *Client) InstanceAttributeValue(attr string) (string, error) { func (c *Client) InstanceAttributeValue(attr string) (string, error) {
return c.Get("instance/attributes/" + attr) return c.InstanceAttributeValueWithContext(context.Background(), attr)
}
// InstanceAttributeValueWithContext returns the value of the provided VM
// instance attribute.
//
// If the requested attribute is not defined, the returned error will
// be of type NotDefinedError.
//
// InstanceAttributeValue may return ("", nil) if the attribute was
// defined to be the empty string.
func (c *Client) InstanceAttributeValueWithContext(ctx context.Context, attr string) (string, error) {
return c.GetWithContext(ctx, "instance/attributes/"+attr)
} }
// ProjectAttributeValue returns the value of the provided // ProjectAttributeValue returns the value of the provided
@@ -469,39 +732,71 @@ func (c *Client) InstanceAttributeValue(attr string) (string, error) {
// //
// ProjectAttributeValue may return ("", nil) if the attribute was // ProjectAttributeValue may return ("", nil) if the attribute was
// defined to be the empty string. // defined to be the empty string.
//
// Deprecated: Please use the context aware variant [Client.ProjectAttributeValueWithContext].
func (c *Client) ProjectAttributeValue(attr string) (string, error) { func (c *Client) ProjectAttributeValue(attr string) (string, error) {
return c.Get("project/attributes/" + attr) return c.ProjectAttributeValueWithContext(context.Background(), attr)
}
// ProjectAttributeValueWithContext returns the value of the provided
// project attribute.
//
// If the requested attribute is not defined, the returned error will
// be of type NotDefinedError.
//
// ProjectAttributeValue may return ("", nil) if the attribute was
// defined to be the empty string.
func (c *Client) ProjectAttributeValueWithContext(ctx context.Context, attr string) (string, error) {
return c.GetWithContext(ctx, "project/attributes/"+attr)
} }
// Scopes returns the service account scopes for the given account. // Scopes returns the service account scopes for the given account.
// The account may be empty or the string "default" to use the instance's // The account may be empty or the string "default" to use the instance's
// main account. // main account.
//
// Deprecated: Please use the context aware variant [Client.ScopesWithContext].
func (c *Client) Scopes(serviceAccount string) ([]string, error) { func (c *Client) Scopes(serviceAccount string) ([]string, error) {
return c.ScopesWithContext(context.Background(), serviceAccount)
}
// ScopesWithContext returns the service account scopes for the given account.
// The account may be empty or the string "default" to use the instance's
// main account.
func (c *Client) ScopesWithContext(ctx context.Context, serviceAccount string) ([]string, error) {
if serviceAccount == "" { if serviceAccount == "" {
serviceAccount = "default" serviceAccount = "default"
} }
return c.lines("instance/service-accounts/" + serviceAccount + "/scopes") return c.lines(ctx, "instance/service-accounts/"+serviceAccount+"/scopes")
} }
// Subscribe subscribes to a value from the metadata service. // Subscribe subscribes to a value from the metadata service.
// The suffix is appended to "http://${GCE_METADATA_HOST}/computeMetadata/v1/". // The suffix is appended to "http://${GCE_METADATA_HOST}/computeMetadata/v1/".
// The suffix may contain query parameters. // The suffix may contain query parameters.
// //
// Subscribe calls fn with the latest metadata value indicated by the provided // Deprecated: Please use the context aware variant [Client.SubscribeWithContext].
// suffix. If the metadata value is deleted, fn is called with the empty string
// and ok false. Subscribe blocks until fn returns a non-nil error or the value
// is deleted. Subscribe returns the error value returned from the last call to
// fn, which may be nil when ok == false.
func (c *Client) Subscribe(suffix string, fn func(v string, ok bool) error) error { func (c *Client) Subscribe(suffix string, fn func(v string, ok bool) error) error {
return c.SubscribeWithContext(context.Background(), suffix, func(ctx context.Context, v string, ok bool) error { return fn(v, ok) })
}
// SubscribeWithContext subscribes to a value from the metadata service.
// The suffix is appended to "http://${GCE_METADATA_HOST}/computeMetadata/v1/".
// The suffix may contain query parameters.
//
// SubscribeWithContext calls fn with the latest metadata value indicated by the
// provided suffix. If the metadata value is deleted, fn is called with the
// empty string and ok false. Subscribe blocks until fn returns a non-nil error
// or the value is deleted. Subscribe returns the error value returned from the
// last call to fn, which may be nil when ok == false.
func (c *Client) SubscribeWithContext(ctx context.Context, suffix string, fn func(ctx context.Context, v string, ok bool) error) error {
const failedSubscribeSleep = time.Second * 5 const failedSubscribeSleep = time.Second * 5
// First check to see if the metadata value exists at all. // First check to see if the metadata value exists at all.
val, lastETag, err := c.getETag(suffix) val, lastETag, err := c.getETag(ctx, suffix)
if err != nil { if err != nil {
return err return err
} }
if err := fn(val, true); err != nil { if err := fn(ctx, val, true); err != nil {
return err return err
} }
@@ -512,7 +807,7 @@ func (c *Client) Subscribe(suffix string, fn func(v string, ok bool) error) erro
suffix += "?wait_for_change=true&last_etag=" suffix += "?wait_for_change=true&last_etag="
} }
for { for {
val, etag, err := c.getETag(suffix + url.QueryEscape(lastETag)) val, etag, err := c.getETag(ctx, suffix+url.QueryEscape(lastETag))
if err != nil { if err != nil {
if _, deleted := err.(NotDefinedError); !deleted { if _, deleted := err.(NotDefinedError); !deleted {
time.Sleep(failedSubscribeSleep) time.Sleep(failedSubscribeSleep)
@@ -522,7 +817,7 @@ func (c *Client) Subscribe(suffix string, fn func(v string, ok bool) error) erro
} }
lastETag = etag lastETag = etag
if err := fn(val, ok); err != nil || !ok { if err := fn(ctx, val, ok); err != nil || !ok {
return err return err
} }
} }
+1 -1
View File
@@ -27,7 +27,7 @@ const (
) )
var ( var (
syscallRetryable = func(err error) bool { return false } syscallRetryable = func(error) bool { return false }
) )
// defaultBackoff is basically equivalent to gax.Backoff without the need for // defaultBackoff is basically equivalent to gax.Backoff without the need for
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows && !linux
package metadata
// systemInfoSuggestsGCE reports whether the local system (without
// doing network requests) suggests that we're running on GCE. If this
// returns true, testOnGCE tries a bit harder to reach its metadata
// server.
func systemInfoSuggestsGCE() bool {
// We don't currently have checks for other GOOS
return false
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build linux
package metadata
import (
"os"
"strings"
)
func systemInfoSuggestsGCE() bool {
b, _ := os.ReadFile("/sys/class/dmi/id/product_name")
name := strings.TrimSpace(string(b))
return name == "Google" || name == "Google Compute Engine"
}
@@ -0,0 +1,38 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package metadata
import (
"strings"
"golang.org/x/sys/windows/registry"
)
func systemInfoSuggestsGCE() bool {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\HardwareConfig\Current`, registry.QUERY_VALUE)
if err != nil {
return false
}
defer k.Close()
s, _, err := k.GetStringValue("SystemProductName")
if err != nil {
return false
}
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "Google")
}

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