Compare commits

..
521 Commits
Author SHA1 Message Date
Umputun 0521c7024c attempt to fix ci
another fix for ci

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

* do not add font description to production

* add correct image for favicon

* fix .dockerignore

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* create infrastructure for site

* wip

* fix docker build and add readme

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

* rename compose file yaml -> yml

* add `src` as volume for watching changes

* update configs

* update README

* add padding at the end of the pages

* move demo settings in config

* fetch latest release from github

* update docs navigation

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

* cache github data from first load

* add redirects and fix link to docs

* fix docs nav styles

* add installation page placeholder

* fix demo

* add 404

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

* fix dark theme on main page

* fix dark theme background

* fix node version

* change installation docs

* add note block

* minor fixes

* add code highlighting styles

* fixes

* fixes

* mobile navigation, fix code highlighting colors

* fix dev server

* fix fetching error

* fix path to edit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* lint: err reassignment

* lint: suppress false positive

* add admin-edit to readme

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-10 02:59:57 -05:00
Dmitry VerkhoturovandUmputun 19ba43b843 dockerfile: remove dead code, bump buildgo 2021-05-07 23:27:58 -05:00
Umputun c055749dae revendor to auth:master to provide fix for 404 avatar 2021-05-07 20:00:24 -05:00
Dmitry VerkhoturovandUmputun 4a2ae04571 replace deprecated golangci-lint check, fix goleak reports 2021-05-07 16:09:28 -05:00
Umputun eb7b34b6b2 add related projects section 2021-05-06 19:27:55 -05:00
Umputun 5bdd1a5c72 lint: remove unneeded lambda 2021-05-06 19:14:07 -05:00
UmputunandGitHub 9838aaac70 Disqus fix (#989) 2021-05-06 18:03:15 -05:00
Pavel MineevandUmputun 048712bcee remove csso 2021-05-06 16:10:25 -05:00
Pavel MineevandUmputun cb40bae89b minify css bundle 2021-05-06 16:10:25 -05:00
Pavel MineevandUmputun d8058c6576 use named imports 2021-05-06 15:55:46 -05:00
Pavel MineevandUmputun 123cd53c09 fix linter 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun 317d932b1f latest versions 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun 58d8fe0f1c fix linter errors 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun 3c6df9e17e bump 2021-05-06 15:39:57 -05:00
Pavel MineevandUmputun b3beca1559 make classnames short 2021-05-06 15:16:39 -05:00
Pavel MineevandUmputun 1ee571a7f5 remove @types/classnames 2021-05-06 15:11:36 -05:00
Pavel MineevandUmputun 9f98b46351 replace classnames to faster and smaller package 2021-05-06 15:11:36 -05:00
Pavel MineevandUmputun 928be7d688 add type module while load modern js bundle 2021-05-06 15:08:20 -05:00
Pavel MineevandUmputun 5782495b8e add new error to locales 2021-05-06 15:08:03 -05:00
Pavel MineevandUmputun 3ad7ba2c09 make form available after failed commit sending 2021-05-06 15:08:03 -05:00
Yuriy KarpovandUmputun 4e0a4e52bd add the ability to configure the 'simple_view' parameter from the client,
the parameter is not required, but if set, it will overwrite the one that came from the backend
issues-916
2021-05-06 11:08:15 -05:00
Dmitry VerkhoturovandUmputun e841bc27fb clarify --secret usage based on #861 2021-05-05 02:39:32 -05:00
Umputun d361564815 fix user view in find for deleted comments #972 2021-05-05 01:15:03 -05:00
Umputun d3bd966df8 switch version detection to /script/version.sh in artifact release 2021-05-04 18:28:49 -05:00
Umputun 82f8f6c93e drop branch image builds 2021-05-04 18:05:11 -05:00
Pavel MineevandUmputun f4b6d73a4a use remark_config.host for oauth links 2021-05-04 03:55:52 -05:00
konstantin krivleniaandUmputun 488518cd72 update be translation 2021-05-04 03:55:29 -05:00
Umputun 692a9bdc01 rename release target 2021-05-03 23:36:03 -05:00
Pavel MineevandUmputun d1405ac479 regenerate i18n 2021-05-03 14:00:14 -05:00
Dmitry KandUmputun 01359a4fa9 Add translations: Japanese, Korean, Brazilian Portuguese
Add translations for Japanese, Korean, Brazilian Portuguese

note: This PR requires generating the code to enable them properly as described in [How to add a new translation](https://remark42.com/docs/latest/translation/#how-to-add-a-new-translation)
2021-05-03 14:00:14 -05:00
Dmitry KandUmputun 4bae705f5c Delete ko.json 2021-05-03 13:47:14 -05:00
Dmitry KandUmputun ef089df96c Delete ja.json 2021-05-03 13:47:14 -05:00
Dmitry KandUmputun b066c929fa Delete bp.json 2021-05-03 13:47:14 -05:00
Dmitry KandUmputun 4dd04d95ee Update Japanese, Brazilian Portuguese 2021-05-03 13:47:14 -05:00
Dmitry KandUmputun f4e5ee06ab Update translations
Update translations for 
- Brazilian Portuguese (`bp.json`) // Not used at the moment, would be nice to support it in [loadLocale.ts](https://github.com/umputun/remark42/blob/master/frontend/app/utils/loadLocale.ts)
- German, 
- Spanish, 
- French, 
- Japanese, 
- Korean, 
- Russian
- Turkish
- Chinese (Simplified)
2021-05-03 13:47:14 -05:00
Pavel Mineev b748771fed allow spaces in username with trim 2021-05-02 18:55:07 +03:00
Umputun a4f2542924 allow admin email login with restricted names #964 2021-04-30 12:00:24 -05:00
Pavel MineevandUmputun befb079207 fix default avatar
- fix import of default avatar
- optimaze ghost svg
- add tests
- refactor avatar-icon
2021-04-28 14:13:02 -05:00
Umputun 627ae58ecc Merge remote-tracking branch 'origin/master' 2021-04-27 00:31:18 -05:00
UmputunandGitHub d5246c9689 Deps bump (#956)
* remove redundant sprintf

* bump many deps

* missing vendors
2021-04-27 00:30:38 -05:00
Umputun abd628e1dc remove redundant sprintf 2021-04-26 23:59:39 -05:00
Umputun 52084e0510 update bluemonday and related deps 2021-04-23 03:44:17 -05:00
ronanandUmputun df4b0c80ac Support for Slack notifications
Slack notifications: remove debug log

Slack notifications: code lint

Slack notifications: code lint

Slack notifications: code lint

fix according to comments
2021-04-21 12:56:14 -05:00
Pavel MineevandUmputun c9841b81e4 move back css for last comments 2021-04-18 17:18:34 -05:00
Pavel MineevandUmputun dfed7e16ba Update README.md for v1.7 2021-04-16 17:49:49 -05:00
Pavel MineevandUmputun 6cf2849e70 rewrite tests in recommended way and add more cases coverage 2021-04-09 11:07:12 -05:00
Pavel MineevandUmputun c9ce2bbf8c use named functions instead of arrows 2021-04-09 11:07:12 -05:00
Pavel MineevandUmputun adb443571f Fix form footer 2021-04-09 11:07:12 -05:00
Pavel MineevandUmputun 8cbdf36bdd Fix error message on token tab on login 2021-04-09 11:07:12 -05:00
aepetelinandUmputun 65fca4c7e5 change parseQuery implementation to URLSearchParams. I use .forEach() because not all browsers support .entries() 2021-04-06 02:15:53 -05:00
aepetelinandUmputun 41e07c2238 fix link to doc/latest/translation.md in Frontend guide, was 404 2021-04-05 14:54:44 -05:00
aepetelinandUmputun 2f03f11c7e fix link to Frontend guide in README 2021-04-05 11:04:23 -05:00
UmputunandGitHub 7153af90d0 Create SECURITY.md 2021-04-02 20:10:59 -05:00
Umputun 961b8aacd9 update bluemonday to v1.0.5 to fix https://github.com/microcosm-cc/bluemonday/issues/111 2021-04-02 19:41:16 -05:00
johnwiiandUmputun 8ef87f400a #add auth source title international language support
with zh.json and en.json.
2021-03-30 11:13:50 -05:00
UmputunandGitHub 0b13e8a8d2 update backend deps from go-pkgz (#935) 2021-03-29 16:24:59 -05:00
Dmitry VerkhoturovandUmputun 5fabbe33d9 make flapping notify drops test less sensitive 2021-03-29 12:48:37 -05:00
William Gerald BlondelandUmputun 70833feb50 Revert default locale to en 2021-03-29 12:45:44 -05:00
William Gerald BlondelandUmputun 3a62e9e93b Add French translation (fr-FR) 2021-03-29 12:45:44 -05:00
Dmitry VerkhoturovandUmputun 8a3f434bc1 fix flapping notify drops test, clarify debug logs 2021-03-28 14:06:58 -05:00
Umputun aa14069f8b lint: fix func comment 2021-03-26 16:05:41 -05:00
Umputun ddbceb5375 post-sanitize location's url in case if stored already #927
this is done via service.alterComment the same way as we handle
leftovers from prev issues with results stored already
2021-03-26 16:00:50 -05:00
Umputun e72f594134 rollback 2021-03-26 15:45:49 -05:00
Umputun 47fee90c60 post-sanitize location's url in case if stored already #927 2021-03-26 15:37:53 -05:00
Umputun 5ed4c7100c shorter match condition 2021-03-26 15:06:39 -05:00
Umputun e762ea7b91 sanitize both locator.URL and user.Picture 2021-03-26 15:06:39 -05:00
Umputun 6f35928396 update bluemonday version 2021-03-26 15:06:39 -05:00
Umputun d624526473 adjust email test to be less go version-specific 2021-03-26 12:51:25 -05:00
William Gerald BlondelandUmputun 2238ec88f0 NPM scripts now work on Windows systems 2021-03-26 02:20:54 -05:00
Pavel MineevandUmputun e7950fcb22 Fix saving comment to LS
* save comment after image upload
* do not affect error messages by save to LS mechanism
* check if data in LS in empty
2021-03-22 03:42:03 -05:00
Michael PichtandUmputun 4254169636 corrected path in docker command to import comments from disqus
Signed-off-by: Michael Picht <mipi@fsfe.org>
2021-03-18 00:47:49 -05:00
Pavel MineevandUmputun 682758a58e resize only comments iframe, prevent to resize iframe by comments info 2021-03-14 04:16:33 -05:00
Pavel MineevandUmputun 4325a3c249 Fix dropdown opening
* change iframe height on content change
* change iframe size on dropdown open
2021-03-13 12:41:25 -06:00
Pavel MineevandUmputun 78c582493c don't add margin on user info iframe 2021-03-13 12:40:27 -06:00
Pavel MineevandUmputun ea4636afbb Fix iframe folding 2021-03-13 12:40:27 -06:00
Pavel MineevandUmputun 345ee88333 add testing-library/jest-dom, fix waitfor 2021-03-04 12:02:46 -06:00
Pavel MineevandUmputun 4bea9f1d37 new-auth: increase limit for remark.css 2021-03-04 12:02:46 -06:00
Pavel MineevandUmputun f922a51a28 new-auth: add tests
* use testing-libruaby for new tests
* tests for auth module
* add svg stub
* add global stub for `useIntl`
* add global stub for `common/settings`
2021-03-04 12:02:46 -06:00
Pavel MineevandUmputun 63218ac51c new-auth 2021-03-04 12:02:46 -06:00
UmputunandGitHub 553bcf2fd5 Merge pull request #892 from umputun/fetch-mock
Make fetch mock global
2021-02-24 12:56:55 -06:00
UmputunandGitHub 71be684739 Merge pull request #894 from umputun/bump-deps
bump deps
2021-02-24 12:56:39 -06:00
Pavel MineevandGitHub bd6ecedacb Merge branch 'master' into bump-deps 2021-02-24 18:33:00 +00:00
Pavel MineevandGitHub a2ecfa65d0 Merge branch 'master' into fetch-mock 2021-02-24 18:29:37 +00:00
Pavel MineevandUmputun ec62b1a572 refac textarea autosize 2021-02-24 12:27:32 -06:00
Pavel MineevandUmputun 23b5a0c6bb fix webpack config 2021-02-24 12:26:37 -06:00
Pavel MineevandUmputun 719b07ec6c remove unused hook 2021-02-24 12:26:13 -06:00
Pavel Mineev 0a4c8dbe45 clear localstorage-mock after each test 2021-02-24 14:56:22 +00:00
Pavel Mineev 982226e48b increase size limit after updating deps 2021-02-24 17:44:45 +03:00
Pavel Mineev 313e34a0b5 bump deps
- remove additional prettier presets because they was included inside default preset
2021-02-24 17:42:02 +03:00
Pavel MineevandUmputun 4a43092fb5 ignore autogenerated loadLocale.ts in coverage report 2021-02-22 02:13:03 -06:00
Pavel Mineev b184bb5b7b Make fetch mock global 2021-02-22 00:15:31 +03:00
Umputun d6019e55b0 update limiter deps 2021-02-21 14:04:21 -06:00
VeselovAlexandUmputun 5ec871fd37 Fix NPE when sending message to detached frame 2021-02-21 13:51:02 -06:00
Pavel MineevandUmputun 111b7a86eb change fetcher for user delete comment action 2021-02-21 13:32:57 -06:00
Aleksandr VeselovandGitHub a38fd1fdc3 Encode e-mail subject to support non-ASCII chars (#888)
* Encode e-mail subject to support non-ASCII chars

* Add test for email subject with unicode

* Fix wrong test argument

* Cleanup test function
2021-02-18 16:23:53 -06:00
Umputun 807160dcf6 prep admin to support multiple keys, per aud (siteID) 2021-02-15 13:14:23 -06:00
UmputunandGitHub a50adc823b Merge pull request #881 from Kickoman/be-translation
Add Belarusian locale
2021-02-13 13:13:42 -06:00
UmputunandGitHub c4832ff47a Merge pull request #887 from umputun/remove-content-type-on-upload
Do not send Content-Type on file upload
2021-02-13 13:12:25 -06:00
Pavel Mineev f4f4764644 Do not send Content-Type on file upload 2021-02-13 21:52:48 +03:00
Umputun 0496c6bb0e encode discuss user_id made from the name #879 2021-02-12 14:52:00 -06:00
Enes ÇakırandGitHub fb23cd6cf7 Fix empty username check for Disqus migrator (#879)
by @enescakir

* Fix user ID encoding for empty string
* Revert "Fix user ID encoding for empty string"

This reverts commit 6d901e4b11.
* Fix empty username check for Disqus migrator
* Fix linter emptyStringTest error
2021-02-12 14:47:19 -06:00
Kanstancin Novikaŭ 1f755e07a4 Belarusian locale introduced 2021-02-12 17:49:40 +03:00
Pavel MineevandUmputun 008788ad89 rollback readme till release 2021-02-10 01:59:36 -06:00
Pavel MineevandUmputun 3628467367 fix oauth providers (turn back site id) 2021-02-04 13:09:59 -06:00
Pavel MineevandUmputun 2e43f09636 Revert "send cookie with request"
This reverts commit 656ab84381.
2021-02-04 13:09:59 -06:00
Pavel MineevandUmputun f515676a72 Revert "add fetcher params to test"
This reverts commit a89a06a583.
2021-02-04 13:09:59 -06:00
Pavel MineevandUmputun a89a06a583 add fetcher params to test 2021-02-04 12:13:52 -06:00
Pavel MineevandUmputun 656ab84381 send cookie with request 2021-02-04 12:13:52 -06:00
Pavel MineevandUmputun 807249db5d remove export of fetcher creator and use predefined methods 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 4ab2ea9246 fix default export for tests 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 6d0b18f688 move creation of all fetchers to fetcher.ts 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 068eaea86a fix types for fetcher methods 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun a1cf289845 Use straight declaration of available methods 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 30394b0760 fix link to oauth window 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 4d075f4f7a fix last comments request 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 8572f809cc rid of legacy bundles from size-limit 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun f31146a266 add fetcher creator, create fetcher for every case, remove api.test.ts because of test become usless in terms of using URLSearchParams 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 49b70b7b04 Add comments to fetcher 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 8161182e7d Pass query throw args 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 2fae0c29d2 Update fetcher and add good coverage to it 2021-02-02 15:53:40 -06:00
Pavel MineevandUmputun 77825ce947 Update frontend CI 2021-02-01 12:28:36 -06:00
Pavel MineevandUmputun e1e8da4b2a use parens for arrow funcs always 2021-01-29 03:17:10 -06:00
UmputunandGitHub be2f6d0a20 Fix admin names (#858)
* fix admin name check for anon login #605

* update readme with admin names info

* lint: list of static site params

* typo

* don't allow email users to reuse admin names

* move admin.names to restricted-names

* forgotten names member

* remove names from example admin

* remove names from prepTestStore
2021-01-20 13:12:09 -06:00
Umputun d40e354265 add more tests for voted_ip 2021-01-18 15:44:32 -06:00
Umputun 1048355b7b strip voted_ip from response 2021-01-18 15:44:32 -06:00
Umputun 5296074dae fix sanitize policy to allow loading-lazy for images #855 2021-01-18 15:36:15 -06:00
Umputun 89a4677391 enforce loading=lazy att to rendered images html 2021-01-17 16:32:26 -06:00
Umputun 849c133082 reformat long error calls to multi-line 2021-01-17 14:21:58 -06:00
Pavel MineevandUmputun ad25f83954 Add size-limit to CI 2021-01-15 19:53:51 -06:00
Umputun 6d525e4820 remove no-cache from /api/v1/avatar api 2021-01-15 16:22:45 -06:00
Umputun 12b0fbe75d remove no-cache from /avatar api 2021-01-15 16:18:10 -06:00
Umputun 0dd3cd93ee add no-cache to Cache-Control on static files 2021-01-15 14:55:32 -06:00
Pavel MineevandUmputun 758c6bb1b2 fix font in user comments 2021-01-15 12:39:20 -06:00
Umputun 477ee60068 switch to stable auth version 2021-01-15 12:38:05 -06:00
Yury AntonovandUmputun 8b53c68cc3 add missing Fragment import 2021-01-15 12:12:19 -06:00
Umputun fa22e32757 revendor with update auth #784 2021-01-15 11:37:07 -06:00
UmputunandGitHub aff79a6fa9 Same site (#850)
* switch to auth master for same-site

* add same-site policy support #784

* add parse same site param
2021-01-14 13:06:29 -06:00
Pavel MineevandUmputun 7f7973a78f Propper settings for collect coverage 2021-01-14 12:11:22 -06:00
NghiamasterandUmputun fa2433ee0d Update Vietnamese translation 2021-01-14 11:24:39 -06:00
NghiamasterandUmputun 0808599442 Update Vietnamese translation 2021-01-14 11:24:39 -06:00
NghiamasterandUmputun 3961226e3c Add Vietnamese locale 2021-01-14 11:24:39 -06:00
NghiamasterandUmputun 277ebf17f3 Add Vietnamese Language 2021-01-14 11:24:39 -06:00
Pavel MineevandUmputun 3e0f233c5a add mjs for sed 2021-01-13 23:16:29 -06:00
Pavel MineevandUmputun eb6ef87d42 set fallback for remark_config.host 2021-01-12 16:21:31 -06:00
Pavel MineevandUmputun df92500bb4 add size-limit to css 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun e429936831 replace file-loader with url-loader for inline svg to css 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 43935bbd38 fix translation tasks for compatibility with latest version on formatjs 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun f332ab194d turn back translaton scripts 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 42cc226b16 turn back dotenv 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun bb5907d21f Replacing REMARK_URL, fix remark_config stub, add minify html/css in templates 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun e77ffef4f4 fix comments locator in example 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun ecc480f51c rewrite script import in iframe.ejs 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 27e0fa0701 change styles for list-comments 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 1052898589 Change fragments to empty tag 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun b72f4d57ea Fix paths to static 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 6474821271 Fix styles order 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun b53d4a455b Fix escaping for shortcuts in messages 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 3bd1bb1c89 fix build 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun a144dd00e3 Remove any from codebase 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 9518d1d83d Update copy plugin 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 67188880a7 Changes
* turn back remark.image
* fix readme
* remove ts-ignore
2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun fb60840f9f fix linter 2021-01-12 13:39:26 -06:00
Pavel MineevandUmputun 5825a55b69 Update frontend
* change root dit and change way to import modules
* update deps to latest versions
* use latest tools for building bundles
* rewrite webpack config
* use nomodule technique for loading modern bundle
* inject polyfills by babel usebuiltins
* update eslint rules
* rename all style files to CSS
* use postcss preset env for building styles
* proper typescript typing
* put all html files to templates folder
* etc
2021-01-12 13:39:26 -06:00
Janik VonrotzandUmputun 919fb7af62 Update README.md 2021-01-05 11:11:29 -06:00
Umputun ed3cd197dd bump deps 2021-01-03 03:55:00 -06:00
Umputun a0b2bbf1ed typo 2021-01-03 03:14:19 -06:00
Umputun 2a02a946e7 lint: make scopelint happy on test 2021-01-03 03:13:20 -06:00
Umputun 1e96f9a5b0 Merge remote-tracking branch 'origin/master' 2021-01-03 03:09:31 -06:00
Umputun 0cb0232fa1 escape [ ] ( ) from tg title #839 2021-01-03 03:09:21 -06:00
Umputun eb21a05b7b add optional frame-ancestors (Content-Security-Policy) for the list of hosts passed by user 2021-01-03 02:33:51 -06:00
Umputun b15f07b5f7 update auth module 2021-01-03 02:22:23 -06:00
a.kalinandUmputun 81acaef8a4 #819 Changed jwt storage to local variable 2020-12-30 13:16:23 -06:00
a.kalinandUmputun c538068d8b #819 Added jwt token to requests 2020-12-30 13:16:23 -06:00
UmputunandGitHub 9b0fee0737 Merge pull request #832 from umputun/dependabot/npm_and_yarn/frontend/ini-1.3.7
Bump ini from 1.3.5 to 1.3.7 in /frontend
2020-12-21 16:25:57 -06:00
UmputunandGitHub d27a22a6d6 Merge pull request #835 from umputun/dependabot/npm_and_yarn/frontend/node-notifier-8.0.1
Bump node-notifier from 8.0.0 to 8.0.1 in /frontend
2020-12-21 16:25:21 -06:00
dependabot[bot]andGitHub 84d06ca392 Bump node-notifier from 8.0.0 to 8.0.1 in /frontend
Bumps [node-notifier](https://github.com/mikaelbr/node-notifier) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/mikaelbr/node-notifier/releases)
- [Changelog](https://github.com/mikaelbr/node-notifier/blob/v8.0.1/CHANGELOG.md)
- [Commits](https://github.com/mikaelbr/node-notifier/compare/v8.0.0...v8.0.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-21 22:22:19 +00:00
Umputun 104ee308c6 make init to work in alpine, fix typo 2020-12-17 21:25:51 -06:00
dependabot[bot]andGitHub 8306b528e0 Bump ini from 1.3.5 to 1.3.7 in /frontend
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.7.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.7)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-11 05:01:38 +00:00
UmputunandGitHub 2a9b59ebf7 Merge pull request #827 from umputun/paskal/fix_doc
readme: fix SPA link, web config syntax
2020-12-06 14:40:54 -06:00
UmputunandGitHub 097788a772 Merge pull request #828 from umputun/paskal/logs
documentation: fix email templating doc, add remap doc
2020-12-06 14:39:49 -06:00
Dmitry Verkhoturov d0d09604e1 add remap doc by @mind-overflow #810 2020-12-06 20:08:26 +01:00
Dmitry Verkhoturov 3111c0e722 fix formatting for email templating doc 2020-12-06 20:02:42 +01:00
Dmitry Verkhoturov b426055ad0 readme: fix SPA link, web config syntax 2020-12-06 18:50:53 +01:00
UmputunandGitHub 4f4c302a9d Merge pull request #826 from umputun/paskal/clean_stream
Clean unused stream code
2020-11-30 10:46:33 -06:00
Dmitry Verkhoturov 809a7c7dbd Clean unused stream code
It was supposed to solve #253 but frontend part for it in
#357 was never finished, and backend code produces false
positive test failures since day 0. The cost of just having
this code around is too high, we'll re-add it in case
frontend implementation will be finished.
2020-11-30 02:00:59 +01:00
Dmitry VerkhoturovandUmputun 6426ea154e #819 add SendJWTHeader auth option, expose it on /config 2020-11-29 18:18:22 -06:00
KsiniaandUmputun 5b4e492c1b Save logged in user email in local storage 2020-11-18 10:59:44 -06:00
UmputunandGitHub e935fedd60 Merge pull request #805 from markiewiczjulian/master
added translations to pl language
2020-11-17 11:25:40 -06:00
Julian MarkiewiczandGitHub 2df51fe111 Merge branch 'master' into master 2020-11-17 09:02:32 +01:00
sattelliteandUmputun 60f554f2fa Simplified definition of REMARK_URL to templates in webpack config 2020-11-10 11:04:07 -06:00
sattelliteandUmputun abe3b0d47a Simplified js expression in templates
REMARK_URL is required and window.location.origin is not needed.

https://github.com/umputun/remark42/pull/806#discussion_r519458452
2020-11-10 11:04:07 -06:00
sattelliteandUmputun 06c3406e30 Replace predefined site_id by SITE_ID 2020-11-10 11:04:07 -06:00
sattelliteandUmputun 0d42761a12 Replace BASE_URL constant by REMARK_URL in html 2020-11-10 11:04:07 -06:00
sattelliteandUmputun 2a48cc9682 use REMARK_URL in frontend templates 2020-11-10 11:04:07 -06:00
aa048a0db9 Apply suggestions from code review
Co-authored-by: Pavel Mineev <pavel@mineev.me>
2020-11-10 09:45:48 -06:00
KsiniaandUmputun d87135a80a Fix test for Replies link 2020-11-10 09:45:48 -06:00
KsiniaandUmputun e6aa4736ea Fix RSS links 2020-11-10 09:45:48 -06:00
Pavel MineevandUmputun f4aa7332d5 Add tests and change props/state getting to fix TS error 2020-11-09 15:43:03 -06:00
Pavel MineevandUmputun 4da83d9231 #212: Fix max length counter in Safari 2020-11-09 15:43:03 -06:00
ivanezkoandUmputun 18ba05cea0 add ukranian locale 2020-11-06 02:15:04 -06:00
ivanezkoandUmputun a8752bb41c add ukranian locale 2020-11-06 02:15:04 -06:00
ivanezkoandUmputun 3396c56f2e add ukranian locale 2020-11-06 02:15:04 -06:00
ivanezkoandUmputun f281faa0da add ukranian locale 2020-11-06 02:15:04 -06:00
IvanezkoandUmputun b88f99e099 add ukranian locale 2020-11-06 02:15:04 -06:00
Artur MullakhmetovandUmputun b04eba2d91 do not decrease comments count in case of repeated deletion 2020-11-05 11:00:30 -06:00
markiewicz.julian@gmail.com 48ed23e70d added translations to pl language 2020-10-28 08:50:51 +01:00
Vitaly ZdanevichandUmputun 55a8eb10e7 опечатка 2020-10-23 21:21:08 -05:00
Dmitry VerkhoturovandUmputun b447863542 add deduplication of emails in recursive notifications 2020-10-20 11:08:04 -05:00
Dmitry VerkhoturovandUmputun 4ba32141fa add recursive email notifications up to highest parent 2020-10-20 11:08:04 -05:00
Dmitry VerkhoturovandUmputun 1ce3cf3d1f simplify admin email notifications 2020-10-20 11:02:04 -05:00
Dmitry VerkhoturovandUmputun 91580194e9 clean up formatting and typos in documentation 2020-10-19 02:42:19 -05:00
Dmitry VerkhoturovandUmputun 6f122f93d1 extract Email.buildAndSendMessage from Email.Send 2020-10-18 17:29:44 -05:00
Dmitry VerkhoturovandUmputun ced40b525a move email notification logic into Email struct 2020-10-18 17:29:44 -05:00
Dmitry VerkhoturovandUmputun bcc2fc7a9e use correct case for GitHub mentions 2020-10-14 12:05:15 -05:00
Dmitry VerkhoturovandUmputun c9b395f60d separate notify.SubmitVerification from notify.Submit 2020-10-11 23:13:24 -05:00
Dmitry VerkhoturovandUmputun 1980f30666 rewrite notify.Service.do from range to select 2020-10-11 23:13:24 -05:00
Umputun b4f4c5e777 auto deploy docs 2020-10-11 23:06:34 -05:00
Pavel MineevandUmputun 3e37434483 Fix images paths in docs 2020-10-11 20:03:14 -05:00
Pavel MineevandUmputun 7b1ecccb4b fix files naming add files to menu 2020-10-11 17:12:14 -05:00
Pavel MineevandUmputun fc9882098d arename readme to index 2020-10-11 17:12:14 -05:00
Pavel MineevandUmputun e52499316e split docs by versions 2020-10-11 17:12:14 -05:00
Dmitry VerkhoturovandUmputun bec6bcee0d use template files instead of statik in dev Dockerfile 2020-10-07 19:32:39 -05:00
Dmitry VerkhoturovandUmputun 36dce6c2ac fix golangci-lint timeout, bump email min TLS version 2020-10-07 19:32:39 -05:00
Dmitry VerkhoturovandUmputun 593ca0387f adjust dockerfile to support go test with -race, bump images 2020-10-07 19:32:39 -05:00
konstantin krivleniaandUmputun a1fcef670e update dependencies 2020-10-04 23:46:39 -05:00
dependabot[bot]andUmputun ecb146bf20 Bump node-fetch from 2.6.0 to 2.6.1 in /frontend
Bumps [node-fetch](https://github.com/bitinn/node-fetch) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/bitinn/node-fetch/releases)
- [Changelog](https://github.com/node-fetch/node-fetch/blob/master/docs/CHANGELOG.md)
- [Commits](https://github.com/bitinn/node-fetch/compare/v2.6.0...v2.6.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-10-04 16:25:08 -05:00
Pavel PustovalovandUmputun c02590665f Setup coverage for FE 2020-09-06 14:15:56 -05:00
Pavel PustovalovandUmputun 1cb724a264 Set node version for each job 2020-09-05 12:22:02 -05:00
Pavel PustovalovandUmputun 82de2a1d8f Regenerate package-lock.json 2020-09-05 12:22:02 -05:00
Dmitry VerkhoturovandUmputun b974fd9cc4 add ability to set multiple admin emails for notifications 2020-09-02 15:09:46 -05:00
Umputun 4f135fe91b switch ci and docker build to node 12 2020-08-30 21:58:48 -05:00
Pavel PustovalovandUmputun f72603d967 make FE CI more parallel 2020-08-30 13:58:22 -05:00
Pavel PustovalovandUmputun 0ba8faefc9 change target browsers to defaults 2020-08-30 13:53:25 -05:00
Pavel PustovalovandUmputun 63b879ac45 npm dedupe 2020-08-30 13:53:25 -05:00
Pavel PustovalovandUmputun 07efd657af Update babel & jest 2020-08-30 13:53:25 -05:00
Pavel PustovalovandUmputun dfd66b95b0 Unify target browsers 2020-08-30 13:53:25 -05:00
vdimirandUmputun 295191955a Add function createAppFromCmd to server_test.go 2020-08-26 12:39:31 -05:00
vdimirandUmputun 14bcbfa901 Add function randomPath to rest_test.go 2020-08-26 12:39:31 -05:00
Pavel MineevandUmputun 5c23206d00 Hide email subscription by param from frontend 2020-08-20 18:15:50 -05:00
Pavel MineevandUmputun c9800a3799 Update vurnulable deps
* automatic update deps
* update copy-webpack-plugin to next major version
* update webpack config connected with new copy-webpack-plugin API
2020-08-20 03:58:04 -05:00
Pavel PustovalovandUmputun a59be983f2 Update lint-staged & husky 2020-08-20 02:53:33 -05:00
Pavel PustovalovandUmputun 71e0e98e05 Update prettier 2020-08-20 02:53:33 -05:00
Pavel PustovalovandUmputun 11f222c6e3 Run ui specs on CI 2020-08-20 02:52:13 -05:00
Umputun 1eedcc0eb3 don't allow anon with space prefix or suffix 2020-08-16 21:12:42 -05:00
Pacharapol WithayasakpuntandGitHub 5d6729ddd5 Fix to work without errors in Nuxt Universal (#723)
by @patarapolw 

* allow manual init and destroy for use with Nuxt

* fix onDestroy-related methods

* avoid global scope, and use function scope instead

* add createInstance function to window.REMARK42

* 1. allow DOMNode to be put in remark_config 2. check remark_config before try to attach node

* move createInstance function outside

* update embed.ts

* avoid ?.

* 📚 Docs: doc on how to make it work with SPAs

* 📚 Docs: fix spa.md to be more flexible

*  Feat: add REMARK42::ready event

* remove nuxt-specific terminologies

* tell MutationObserver to disconnect on destroy

* update docs/spa.md
2020-07-31 12:21:00 -05:00
Pacharapol WithayasakpuntandUmputun d1268bbe8c update README according to #737 2020-07-30 12:24:48 -05:00
dependabot[bot]andUmputun 424016e994 Bump elliptic from 6.5.2 to 6.5.3 in /frontend
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-30 12:21:41 -05:00
Umputun 729b6d2d08 add --proxy-cors $PROXY_CORS disabling internal cors middleware #749 2020-07-27 16:58:47 -05:00
dependabot[bot]andUmputun 0f9c41cfc6 Bump lodash from 4.17.15 to 4.17.19 in /frontend
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-22 11:58:04 -05:00
0xflotusandUmputun 8b77ff953e docs: fixed small error 2020-07-22 11:57:44 -05:00
0xflotusandUmputun 69a81042db docs: enabled nginx syntax highlighting 2020-07-22 11:56:49 -05:00
vdimirandUmputun 09f06f587c Add tests for comment.Imported, refine image.SubmitAndCommit tests 2020-07-09 12:43:07 -05:00
vdimirandUmputun d7c9aecf03 Submit images from service.Create only after creation in Engine 2020-07-09 12:43:07 -05:00
vdimirandUmputun 9957ab642b Add Imported field to comment, do not wait image submission for such comments 2020-07-09 12:43:07 -05:00
vdimirandUmputun 1f9566bb47 Use comment timestamp for submitting image ttl 2020-07-09 12:43:07 -05:00
Dmitry VerkhoturovandUmputun 9510962e25 add redis PubSub web cache as an option 2020-06-25 23:14:50 -05:00
Dmitry VerkhoturovandUmputun ebf379f4d6 bump go-pkgz/lcw to v0.7.1 2020-06-25 17:28:33 -05:00
Umputun df9c05b490 add microsoft auth 2020-06-18 18:25:35 -05:00
Dmitry VerkhoturovandUmputun 42d0c67cb3 add context to server errors, properly close datastore in case of errors 2020-06-13 19:07:33 -05:00
Umputun 46f3ced83d disable some stream tests 2020-06-08 16:10:31 -05:00
Umputun 4d0f909f0e add reset of the same ip votes on -+ or +- changes and move same ip set and reject check up (above this reset) 2020-06-08 14:59:40 -05:00
dependabot[bot]andUmputun 1e60cb813a Bump websocket-extensions from 0.1.3 to 0.1.4 in /frontend
Bumps [websocket-extensions](https://github.com/faye/websocket-extensions-node) from 0.1.3 to 0.1.4.
- [Release notes](https://github.com/faye/websocket-extensions-node/releases)
- [Changelog](https://github.com/faye/websocket-extensions-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-extensions-node/compare/0.1.3...0.1.4)

Signed-off-by: dependabot[bot] <support@github.com>
2020-06-07 15:46:51 -05:00
Umputun dc7bb5810f typo in from_address param 2020-06-05 03:49:49 -05:00
Dmitry Verkhoturov 80f4862c9c fix leaking goroutines and add tests for them 2020-06-01 09:58:48 +02:00
Umputun 55dd5bd25b skip LastCommentStream test 2020-05-26 15:29:56 -05:00
Dmitry VerkhoturovandUmputun 890d7154e7 bump go modules 2020-05-24 19:00:07 -05:00
Dmitry VerkhoturovandUmputun 0d67f7e53d bump golangci-lint version, fix discovered issues 2020-05-24 19:00:07 -05:00
foxbgandGitHub 13220c044c Adding BG translation (#719)
* adding BG translation part 1

* agging BG locale part 2

* adding BG translation part 1

* agging BG locale part 2

* adding BG translation part 1

* agging BG locale part 2

* adding BG translation part 1

* agging BG locale part 2

* fixing typo

* remove duplicate line
* more translation and fixed typos

@foxbg
2020-05-24 14:08:48 -05:00
UmputunandGitHub 5c36aca9ec Merge pull request #716 from Mavrin/fix_name_regexp
allow non latin letter for user name
2020-05-23 11:58:39 -05:00
Umputun 2f20b64c0f switch lcw to stable version 2020-05-22 14:18:34 -05:00
Umputun 87b44fa74c switch to tagged base images 2020-05-22 14:13:05 -05:00
konstantin krivlenia 07a9f84c49 allow non latin letter for user name 2020-05-22 00:33:54 +03:00
Umputun 80854f9d40 allow anon names to inlude non-latin. Add limit (64) to max len #715 2020-05-21 13:18:10 -05:00
konstantin krivleniaandUmputun 4682694092 update frontend dev dependencies 2020-05-19 15:11:35 -05:00
Gürkan GürandUmputun 0bb2877b07 frontend: Add Turkish language support 2020-05-17 13:34:40 -05:00
Gürkan GürandUmputun d3b58c0795 docs: Extend translation howto 2020-05-17 13:34:40 -05:00
Umputun d8ce444df0 move logging for protected routes to INFO level 2020-05-12 23:15:06 -05:00
Umputun 7edf8e0b23 pull latest docker image on dev build 2020-05-12 22:27:08 -05:00
Umputun c10354584d dbl request limit size for count query
for sites with a lot of posts on the page, the list of all urls can exceed the default 64k limit. I've see the request like this from news.radio-t.com
2020-05-12 22:26:45 -05:00
Umputun ef8407b23e typo in app image 2020-05-12 12:17:41 -05:00
Umputun 11fde80d27 replace references to legacy remark url in docs 2020-05-12 12:08:18 -05:00
Umputun 6bee151b25 switch base image 2020-05-12 12:02:13 -05:00
Umputun 0895de2f7e rename all packages from the legacy remark/ to remark42 2020-05-12 11:34:16 -05:00
Pavel MineevandUmputun b90d076fdf Move html to separete files 2020-05-10 23:51:13 -05:00
Dmitry Verkhoturov f5fcd5b254 switch from go-cache to lcw 2020-05-10 20:41:26 +02:00
Umputun a8f3e1730b update to lcw@master 2020-05-09 21:20:03 -05:00
Dmitry VerkhoturovandUmputun bbfeaeb3d0 go-consistent proposed changes 2020-05-04 02:22:16 -05:00
Serge AdamovichandUmputun 5afa74eec1 #594 Open hidden thread and jump to linked comment 2020-05-04 02:20:44 -05:00
Serge AdamovichandUmputun d111cbd3e1 #594 Scroll to parent of hidden comments 2020-05-04 02:20:44 -05:00
Dmitry VerkhoturovandUmputun e922fc31a2 enable gocritic linter and fix found issues 2020-04-26 17:05:34 -05:00
2271 changed files with 242606 additions and 174754 deletions
-103
View File
@@ -1,103 +0,0 @@
kind: pipeline
name: default
type: docker
steps:
- name: build server
image: umputun/baseimage:buildgo-latest
commands:
- cd backend/app
- go build -mod=vendor
- echo "build completed"
- name: docker master
image: plugins/docker
settings:
repo: umputun/remark42
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
- DRONE_BRANCH=${DRONE_BRANCH}
tags:
- ${DRONE_COMMIT_BRANCH/\//-}
when:
branch: [master]
event: push
- name: docker tag
image: plugins/docker
settings:
repo: umputun/remark42
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
tags:
- ${DRONE_TAG}
- latest
when:
event: tag
- name: docker branch
image: plugins/docker
settings:
repo: umputun/remark42
username:
from_secret: docker_username
password:
from_secret: docker_password
build_args:
- DRONE=${DRONE}
- DRONE_COMMIT=${DRONE_COMMIT}
- DRONE_BRANCH=${DRONE_BRANCH}
tags:
- ${DRONE_COMMIT_BRANCH/\//-}
dry_run: true
when:
branch:
exclude: [master, release/*]
event: push
- name: artifacts tag
image: plugins/docker
settings:
dockerfile: Dockerfile.artifacts
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
- GITHUB_TOKEN=${GITHUB_TOKEN}
when:
event: tag
- name: deploy
image: docker.umputun.com/system/deploy-ci:master
commands:
- ssh umputun@remark42.com "cd /srv/remark && docker-compose pull"
- ssh umputun@remark42.com "cd /srv/remark && docker-compose up -d"
when:
branch: master
event: push
- name: notify
image: drillster/drone-email
settings:
host: smtp.mailgun.org
username:
from_secret: email_username
password:
from_secret: email_password
from: drone@mg.umputun.dev
recipients: [ sys@umputun.dev ]
when:
status: [ changed, failure ]
+11
View File
@@ -0,0 +1,11 @@
[*]
indent_style = tab
insert_final_newline = true
[*.md]
indent_style = space
trim_trailing_whitespace = false
[*.yml]
indent_size = 2
indent_style = space
+69 -15
View File
@@ -5,27 +5,81 @@ on:
branches:
tags:
paths:
- '.github/workflows/ci-build.yml'
- 'backend/**'
- 'frontend/**'
- '.dockerignore'
- 'docker-init.sh'
- 'Dockerfile'
- ".github/workflows/ci-build.yml"
- "backend/**"
- "frontend/**"
- ".dockerignore"
- "docker-init.sh"
- "Dockerfile"
- "!**.md"
pull_request:
paths:
- '.github/workflows/ci-build.yml'
- 'backend/**'
- 'frontend/**'
- '.dockerignore'
- 'docker-init.sh'
- 'Dockerfile'
- ".github/workflows/ci-build.yml"
- "backend/**"
- "frontend/**"
- ".dockerignore"
- "docker-init.sh"
- "Dockerfile"
- "!**.md"
jobs:
build:
build-images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: build docker image
run: docker build --build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github .
- name: set up QEMU
uses: docker/setup-qemu-action@v1
- name: set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: build and deploy master image to ghcr.io and dockerhub
if: ${{ github.ref == 'refs/heads/master' }}
env:
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
USERNAME: ${{ github.actor }}
GITHUB_SHA: ${{ github.sha}}
GITHUB_REF: ${{ github.ref}}
run: |
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
echo GITHUB_REF - $ref
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
echo ${DOCKER_HUB_TOKEN} | docker login -u ${USERNAME} --password-stdin
docker buildx build --push \
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github \
--build-arg GITHUB_SHA=${GITHUB_SHA} --build-arg GIT_BRANCH=${ref} \
--platform linux/amd64,linux/arm/v7,linux/arm64 \
-t ghcr.io/${USERNAME}/remark42:${ref} -t ${USERNAME}/remark42:${ref} .
- name: deploy tagged (latest) to ghcr.io and dockerhub
if: ${{ startsWith(github.ref, 'refs/tags/') }}
env:
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
USERNAME: ${{ github.actor }}
GITHUB_SHA: ${{ github.sha}}
GITHUB_REF: ${{ github.ref}}
run: |
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
echo "GITHUB_REF=$ref, GITHUB_SHA=${GITHUB_SHA}"
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
echo ${DOCKER_HUB_TOKEN} | docker login -u ${USERNAME} --password-stdin
docker buildx build --push \
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github \
--build-arg GITHUB_SHA=${GITHUB_SHA} --build-arg GIT_BRANCH=${ref} \
--platform linux/amd64,linux/arm/v7,linux/arm64 \
-t ghcr.io/umputun/remark42:${ref} -t ghcr.io/umputun/remark42:latest \
-t umputun/remark42:${ref} -t umputun/remark42:latest .
- name: remote deployment to remark42.com from master
if: ${{ github.ref == 'refs/heads/master' }}
env:
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
run: curl https://jess.umputun.com/update/remark42-core/${UPDATER_KEY}
@@ -0,0 +1,20 @@
name: frontend
on:
pull_request:
paths:
- ".github/workflows/ci-frontend-size-limit.yml"
- "frontend/**"
- "!**.md"
jobs:
size:
runs-on: ubuntu-latest
env:
CI_JOB_NUMBER: 1
steps:
- uses: actions/checkout@v1
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
directory: frontend
+107
View File
@@ -0,0 +1,107 @@
name: frontend
on:
push:
branches:
tags:
paths:
- ".github/workflows/ci-frontend.yml"
- "frontend/**"
- "!**.md"
pull_request:
paths:
- ".github/workflows/ci-frontend.yml"
- "frontend/**"
- "!**.md"
jobs:
check-translations:
runs-on: ubuntu-latest
strategy:
matrix:
node: [14.15]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: npm ci --loglevel warn
working-directory: ./frontend
- uses: actions/cache@v2
with:
path: ${{ github.workspace }}/frontend/node_modules/.cache
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm run check:translation
working-directory: ./frontend
check-typescript:
runs-on: ubuntu-latest
strategy:
matrix:
node: [14.15]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: npm ci --loglevel warn
working-directory: ./frontend
- uses: actions/cache@v2
with:
path: ${{ github.workspace }}/frontend/node_modules/.cache
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm run check:types
working-directory: ./frontend
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node: [14.15]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: npm ci --loglevel warn
working-directory: ./frontend
- run: npx run-p lint
working-directory: ./frontend
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [14.15]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: npm ci --loglevel warn
working-directory: ./frontend
- run: npm run test:coverage
working-directory: ./frontend
- name: submit coverage
run: node ${{ github.workspace }}/frontend/node_modules/.bin/codecov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+68
View File
@@ -0,0 +1,68 @@
name: site
on:
push:
branches:
tags:
paths:
- ".github/workflows/ci-site.yml"
- "site/**"
pull_request:
paths:
- ".github/workflows/ci-site.yml"
- "site/**"
jobs:
build-site:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
- name: set up QEMU
uses: docker/setup-qemu-action@v1
- name: set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: build and deploy master image to ghcr.io and dockerhub
if: ${{ github.ref == 'refs/heads/master' }}
env:
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
USERNAME: ${{ github.actor }}
GITHUB_SHA: ${{ github.sha}}
GITHUB_REF: ${{ github.ref}}
working-directory: ./site
run: |
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
echo GITHUB_REF - $ref
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
docker buildx build --push --no-cache --platform linux/amd64,linux/arm/v7,linux/arm64 \
-t ghcr.io/${USERNAME}/remark24-site:${ref} .
- name: deploy tagged (latest) to ghcr.io and dockerhub
if: ${{ startsWith(github.ref, 'refs/tags/') }}
env:
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
USERNAME: ${{ github.actor }}
GITHUB_SHA: ${{ github.sha}}
GITHUB_REF: ${{ github.ref}}
working-directory: ./site
run: |
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
echo "GITHUB_REF=$ref, GITHUB_SHA=${GITHUB_SHA}"
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
docker buildx build --push --no-cache --platform linux/amd64,linux/arm/v7,linux/arm64 \
-t ghcr.io/${USERNAME}/remark24-site:${ref} -t ghcr.io/${USERNAME}/remark24-site:latest .
- name: remote site deployment from master
if: ${{ github.ref == 'refs/heads/master' }}
env:
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
run: curl https://jess.umputun.com/update/remark42-site/${UPDATER_KEY}
+12 -11
View File
@@ -5,17 +5,18 @@ on:
branches:
tags:
paths:
- '.github/workflows/ci-test-backend.yml'
- 'backend/**'
- '!backend/scripts/**'
- ".github/workflows/ci-test-backend.yml"
- "backend/**"
- "!backend/scripts/**"
- "!**.md"
pull_request:
paths:
- '.github/workflows/ci-test-backend.yml'
- 'backend/**'
- '!backend/scripts/**'
- ".github/workflows/ci-test-backend.yml"
- "backend/**"
- "!backend/scripts/**"
- "!**.md"
jobs:
backend:
test-backend:
runs-on: ubuntu-latest
steps:
@@ -27,13 +28,13 @@ jobs:
DEBUG: ${{secrets.DEBUG}}
- name: install go
uses: actions/setup-go@v1
uses: actions/setup-go@v2
with:
go-version: 1.14
go-version: 1.16
- name: install golangci-lint and goveralls
run: |
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.25.0
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.43.0
go get -u github.com/mattn/goveralls
- name: test and lint backend
-28
View File
@@ -1,28 +0,0 @@
name: test_frontend
on:
push:
branches:
tags:
paths:
- '.github/workflows/ci-test-frontend.yml'
- 'frontend/**'
pull_request:
paths:
- '.github/workflows/ci-test-frontend.yml'
- 'frontend/**'
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- run: npm ci --loglevel warn
working-directory: ./frontend
- run: npx run-p check lint
working-directory: ./frontend
+1 -3
View File
@@ -8,9 +8,6 @@ debug
debug.test
.vscode
.idea/
/frontend/node_modules/
/frontend/public/
/frontend/coverage
*.prof
*.test
/rest-client.env.json
@@ -24,3 +21,4 @@ compose-private-backend.yml
compose-private-frontend.yml
compose-private.yml
/backend/_example/*/vendor
http-client.env.json
+10 -6
View File
@@ -1,4 +1,4 @@
FROM umputun/baseimage:buildgo-latest as build-backend
FROM umputun/baseimage:buildgo-v1.8.0 as build-backend
ARG CI
ARG DRONE
@@ -16,11 +16,14 @@ WORKDIR /build/backend
ENV GOFLAGS="-mod=vendor"
# install gcc in order to be able to go test package with -race
RUN apk --no-cache add gcc libc-dev
# run tests
RUN \
cd app && \
if [ -z "$SKIP_BACKEND_TEST" ] ; then \
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 ; \
golangci-lint run --config ../.golangci.yml ./... ; \
else echo "skip backend tests and linter" ; fi
@@ -32,7 +35,7 @@ RUN \
echo "version=$version" && \
go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app
FROM node:10.11-alpine as build-frontend-deps
FROM node:12.16-alpine as build-frontend-deps
ARG CI
ENV HUSKY_SKIP_INSTALL=true
@@ -42,7 +45,7 @@ ADD frontend/package.json /srv/frontend/package.json
ADD frontend/package-lock.json /srv/frontend/package-lock.json
RUN cd /srv/frontend && CI=true npm ci --loglevel warn
FROM node:10.11-alpine as build-frontend
FROM node:12.16-alpine as build-frontend
ARG CI
ARG SKIP_FRONTEND_TEST
@@ -55,7 +58,7 @@ RUN cd /srv/frontend && \
else echo "skip frontend tests and lint" ; npm run build ; fi && \
rm -rf ./node_modules
FROM umputun/baseimage:app
FROM umputun/baseimage:app-v1.8.0
WORKDIR /srv
@@ -66,13 +69,14 @@ ADD backend/scripts/import.sh /usr/local/bin/import
RUN chmod +x /entrypoint.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
COPY --from=build-backend /build/backend/remark42 /srv/remark42
COPY --from=build-backend /build/backend/templates /srv
COPY --from=build-frontend /srv/frontend/public/ /srv/web
COPY docker-init.sh /srv/init.sh
RUN chown -R app:app /srv
RUN ln -s /srv/remark42 /usr/bin/remark42
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost:8080/ping || exit 1
COPY docker-init.sh /srv/init.sh
RUN chmod +x /srv/init.sh
CMD ["/srv/remark42", "server"]
+8 -26
View File
@@ -1,4 +1,4 @@
FROM node:10.11-alpine as build-frontend-deps
FROM node:12.16-alpine as build-frontend-deps
ARG CI
ARG DRONE
@@ -13,7 +13,7 @@ ADD frontend/package.json /srv/frontend/package.json
ADD frontend/package-lock.json /srv/frontend/package-lock.json
RUN cd /srv/frontend && CI=true npm ci
FROM node:10.11-alpine as build-frontend
FROM node:12.16-alpine as build-frontend
ARG CI
ARG NODE_ENV=production
@@ -31,8 +31,6 @@ FROM umputun/baseimage:buildgo-latest as build-backend
ARG GITHUB_TOKEN
ENV SKIP_BACKEND_TEST=true
RUN go get github.com/rakyll/statik
WORKDIR /build/backend
ADD backend /build/backend
ADD README.md /build/
@@ -44,15 +42,17 @@ COPY --from=build-frontend /srv/frontend/public/ web
RUN \
export WEB_ROOT=/build/backend/web && \
sed -i "s|https://demo.remark42.com|http://127.0.0.1:8080|g" ${WEB_ROOT}/*.js && \
find . -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \; && \
statik --src=${WEB_ROOT} --dest=/build/backend/app/rest -p api -f && \
statik --src=/build/backend/templates --dest=/build/backend/app -p templates -ns templates -f && \
ls -la /build/backend/app/templates/statik.go && \
ls -la /build/backend/app/rest/api/statik.go && \
ls -la /build/backend/web/
# if DRONE presented use DRONE_* git env to make version
RUN \
if [ -z "$DRONE" ] ; then \
echo "runs outside of drone" && version=$(/script/git-rev.sh); \
echo "runs outside of drone" && version=$("/script/version.sh"); \
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S); fi && \
echo "version=$version" && \
export GOFLAGS="-mod=vendor" && \
@@ -62,6 +62,7 @@ RUN \
GOOS=linux GOARCH=arm64 go build -o remark42.linux-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=windows GOARCH=amd64 go build -o remark42.windows-amd64.exe -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=darwin GOARCH=amd64 go build -o remark42.darwin-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=darwin GOARCH=arm64 go build -o remark42.darwin-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=freebsd GOARCH=amd64 go build -o remark42.freebsd-amd64 -ldflags "-X main.revision=${version} -s -w" ./app
RUN \
@@ -75,29 +76,10 @@ RUN \
tar cvzf remark42${tag}.linux-arm.tar.gz remark42.linux-arm LICENSE README.md && \
tar cvzf remark42${tag}.linux-arm64.tar.gz remark42.linux-arm64 LICENSE README.md && \
tar cvzf remark42${tag}.darwin-amd64.tar.gz remark42.darwin-amd64 LICENSE README.md && \
tar cvzf remark42${tag}.darwin-arm64.tar.gz remark42.darwin-arm64 LICENSE README.md && \
tar cvzf remark42${tag}.freebsd-amd64.tar.gz remark42.freebsd-amd64 LICENSE README.md && \
zip remark42${tag}.windows-amd64.zip remark42.windows-amd64.exe LICENSE README.md
# upload to github
#RUN \
# if [ -z "$DRONE_TAG" ] ; then \
# echo "skip upload to github" ; \
# else \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-amd64.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-amd64.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-386.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-386.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-arm64.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-arm64.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.darwin-amd64.tar.gz \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.darwin-amd64.tar.gz" && \
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
# -H "Content-Type: application/zip" --data-binary @remark42_${DRONE_TAG}.windows-amd64.zip \
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.windows-amd64.zip"; fi
FROM alpine
COPY --from=build-backend /build/backend/remark42.* /artifacts/
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 Umputun
Copyright (c) 2021 Umputun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+9 -3
View File
@@ -11,8 +11,13 @@ bin:
docker:
docker build -t umputun/remark42 --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true .
deploy:
docker build -f Dockerfile.artifacts -t remark42.bin .
dockerx:
docker buildx build --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true \
--progress=plain --platform linux/amd64,linux/arm/v7,linux/arm64 \
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
release:
docker build -f Dockerfile.artifacts --no-cache --pull -t remark42.bin .
- @docker rm -f remark42.bin 2>/dev/null || exit 0
- @mkdir -p bin
docker run -d --name=remark42.bin remark42.bin
@@ -34,7 +39,8 @@ frontend:
docker-compose -f compose-dev-frontend.yml build
rundev:
docker pull umputun/baseimage:buildgo-latest
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true docker-compose -f compose-private.yml build
docker-compose -f compose-private.yml up
.PHONY: bin backend
.PHONY: bin backend
+14 -855
View File
@@ -1,10 +1,8 @@
# remark42 [![Build Status](https://github.com/umputun/remark/workflows/build/badge.svg)](https://github.com/umputun/remark/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/umputun/remark)](https://goreportcard.com/report/github.com/umputun/remark) [![Coverage Status](https://coveralls.io/repos/github/umputun/remark/badge.svg?branch=master)](https://coveralls.io/github/umputun/remark?branch=master)
# Remark42 [![Build Status](https://github.com/umputun/remark42/workflows/build/badge.svg)](https://github.com/umputun/remark42/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/umputun/remark42)](https://goreportcard.com/report/github.com/umputun/remark42) [![Coverage Status](https://coveralls.io/repos/github/umputun/remark42/badge.svg?branch=master)](https://coveralls.io/github/umputun/remark42?branch=master) [![codecov](https://codecov.io/gh/umputun/remark42/branch/master/graph/badge.svg)](https://app.codecov.io/gh/umputun/remark42)
Remark42 is a self-hosted, lightweight and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles, or any other place where readers add comments.
Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles or any other place where readers add comments.
* Social login via Google, Twitter, Facebook, GitHub and Yandex
* Social login via Google, Twitter, Facebook, Microsoft, GitHub, Yandex, Patreon and Telegram
* Login via email
* Optional anonymous access
* Multi-level nested comments with both tree and plain presentations
@@ -16,14 +14,15 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
* Images upload with drag-and-drop
* Extractor for recent comments, cross-post
* RSS for all comments and each post
* Telegram and email notifications
* Export data to json with automatic backups
* Telegram, Slack, Webhook and email notifications for Admins (get notified for each new comment)
* Email and Telegram notifications for users (get notified when someone responds to your comment)
* Export data to JSON with automatic backups
* No external databases, everything embedded in a single data file
* Fully dockerized and can be deployed in a single command
* Self-contained executable can be deployed directly to Linux, Windows and MacOS
* Self-contained executable can be deployed directly to Linux, Windows and macOS
* Clean, lightweight and customizable UI with white and dark themes
* Multi-site mode from a single instance
* Integration with automatic ssl (direct and via [nginx-le](https://github.com/umputun/nginx-le))
* Integration with automatic SSL (direct and via [nginx-le](https://github.com/nginx-le/nginx-le))
* [Privacy focused](#privacy)
[Demo site](https://remark42.com/demo/) available with all authentication methods, including email auth and anonymous access.
@@ -31,854 +30,14 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
<details><summary>Screenshots</summary>
Comments example:
![](https://github.com/umputun/remark/blob/master/screenshots/comments.png)
![](screenshots/comments.png)
For admin screenshots see [Admin UI wiki](https://github.com/umputun/remark/wiki/Admin-UI)
For admin screenshots see [Admin UI wiki](https://github.com/umputun/remark42/wiki/Admin-UI)
</details>
All remark42 documentation is available [by the link](https://remark42.com/docs/).
#
## Related projects
- [Install](#install)
- [Backend](#backend)
- [With Docker](#with-docker)
- [Without Docker](#without-docker)
- [Parameters](#parameters)
- [Required parameters](#required-parameters)
- [Quick installation test](#quick-installation-test)
- [Register oauth2 providers](#register-oauth2-providers)
- [Google Auth Provider](#google-auth-provider)
- [GitHub Auth Provider](#github-auth-provider)
- [Facebook Auth Provider](#facebook-auth-provider)
- [Twitter Auth Provider](#twitter-auth-provider)
- [Yandex Auth Provider](#yandex-auth-provider)
- [Initial import from Disqus](#initial-import-from-disqus)
- [Initial import from WordPress](#initial-import-from-wordpress)
- [Backup and restore](#backup-and-restore)
- [Automatic backups](#automatic-backups)
- [Manual backup](#manual-backup)
- [Restore from backup](#restore-from-backup)
- [Backup format](#backup-format)
- [Admin users](#admin-users)
- [Setup on your website](#setup-on-your-website)
- [Comments](#comments)
- [Last comments](#last-comments)
- [Counter](#counter)
- [Build from the source](#build-from-the-source)
- [Development](#development)
- [Backend development](#backend-development)
- [Frontend development](#frontend-development)
- [Build](#build)
- [Devserver](#devserver)
- [API](#api)
- [Authorization](#authorization)
- [Commenting](#commenting)
- [RSS feeds](#rss-feeds)
- [Admin](#admin)
- [Privacy](#privacy)
- [Technical details](#technical-details)
## Install
### Backend
#### With Docker
_this is the recommended way to run remark42_
* copy provided `docker-compose.yml` and customize for your needs
* make sure you **don't keep** `ADMIN_PASSWD=something...` for any non-development deployments
* pull prepared images from the DockerHub and start - `docker-compose pull && docker-compose up -d`
* alternatively compile from the sources - `docker-compose build && docker-compose up -d`
#### Without Docker
* download archive for [stable release](https://github.com/umputun/remark/releases) or [development version](https://remark42.com/downloads)
* unpack with `gunzip` (Linux, macOS) or with `zip` (Windows)
* run as `remark42.{os}-{arch} server {parameters...}`, i.e. `remark42.linux-amd64 server --secret=12345 --url=http://127.0.0.1:8080`
* alternatively compile from the sources - `make OS=[linux|darwin|windows] ARCH=[amd64,386,arm64,arm]`
#### Parameters
| Command line | Environment | Default | Description |
| ----------------------- | ----------------------- | ------------------------ | ----------------------------------------------- |
| url | REMARK_URL | | url to remark42 server, _required_ |
| secret | SECRET | | secret key, _required_ |
| site | SITE | `remark` | site name(s), _multi_ |
| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `rpc` |
| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory |
| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout |
| admin.shared.id | ADMIN_SHARED_ID | | admin names (list of user ids), _multi_ |
| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin email |
| backup | BACKUP_PATH | `./var/backup` | backups location |
| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep |
| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited |
| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited |
| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited |
| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, `bolt`, or `uri` |
| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store |
| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store |
| avatar.uri | AVATAR_URI | `./var/avatars` | avatar store uri |
| avatar.rsz-lmt | AVATAR_RSZ_LMT | `0` (disabled) | max image size for resizing avatars on save |
| image.type | IMAGE_TYPE | `fs` | type of image storage, `fs`, `bolt` |
| image.max-size | IMAGE_MAX_SIZE | `5000000` | max size of image file |
| image.fs.path | IMAGE_FS_PATH | `./var/pictures` | permanent location of images |
| image.fs.staging | IMAGE_FS_STAGING | `./var/pictures.staging` | staging location of images |
| image.fs.partitions | IMAGE_FS_PARTITIONS | `100` | number of image partitions |
| image.bolt.file | IMAGE_BOLT_FILE | `/var/pictures.db` | images bolt file location |
| image.resize-width | IMAGE_RESIZE_WIDTH | `2400` | width of resized image |
| image.resize-height | IMAGE_RESIZE_HEIGHT | `900` | height of resized image |
| auth.ttl.jwt | AUTH_TTL_JWT | `5m` | jwt TTL |
| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | cookie TTL |
| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID |
| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret |
| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID |
| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret |
| auth.github.cid | AUTH_GITHUB_CID | | Github OAuth client ID |
| auth.github.csec | AUTH_GITHUB_CSEC | | Github OAuth client secret |
| auth.twitter.cid | AUTH_TWITTER_CID | | Twitter Consumer API Key |
| auth.twitter.csec | AUTH_TWITTER_CSEC | | Twitter Consumer API Secret key |
| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID |
| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret |
| auth.dev | AUTH_DEV | `false` | local oauth2 server, development mode only |
| auth.anon | AUTH_ANON | `false` | enable anonymous login |
| auth.email.enable | AUTH_EMAIL_ENABLE | `false` | enable auth via email |
| auth.email.from | AUTH_EMAIL_FROM | | email from |
| auth.email.subj | AUTH_EMAIL_SUBJ | `remark42 confirmation` | email subject |
| auth.email.content-type | AUTH_EMAIL_CONTENT_TYPE | `text/html` | email content type |
| auth.email.template | AUTH_EMAIL_TEMPLATE | none (predefined) | custom email message template file |
| notify.type | NOTIFY_TYPE | none | type of notification (telegram and/or email) |
| notify.queue | NOTIFY_QUEUE | `100` | size of notification queue |
| notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token |
| notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel |
| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout |
| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address |
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject |
| notify.email.notify_admin | NOTIFY_EMAIL_ADMIN | `false` | notify admin on new comments via ADMIN_SHARED_EMAIL |
| smtp.host | SMTP_HOST | | SMTP host |
| smtp.port | SMTP_PORT | | SMTP port |
| smtp.username | SMTP_USERNAME | | SMTP user name |
| smtp.password | SMTP_PASSWORD | | SMTP password |
| smtp.tls | SMTP_TLS | | enable TLS for SMTP |
| smtp.timeout | SMTP_TIMEOUT | `10s` | SMTP TCP connection timeout |
| ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le |
| ssl.port | SSL_PORT | `8443` | port for https server |
| ssl.cert | SSL_CERT | | path to cert.pem file |
| ssl.key | SSL_KEY | | path to key.pem file |
| ssl.acme-location | SSL_ACME_LOCATION | `./var/acme` | dir where obtained le-certs will be stored |
| ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE |
| max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit |
| max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited |
| votes-ip | VOTES_IP | `false` | restrict votes from the same ip |
| anon-vote | ANON_VOTE | `false` | allow voting for anonymous users, require VOTES_IP to be enabled as well |
| votes-ip-time | VOTES_IP_TIME | `5m` | same ip vote restriction time, `0s` - unlimited |
| low-score | LOW_SCORE | `-5` | low score threshold |
| critical-score | CRITICAL_SCORE | `-10` | critical score threshold |
| positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive |
| restricted-words | RESTRICTED_WORDS | | words banned in comments (can use `*`), _multi_ |
| edit-time | EDIT_TIME | `5m` | edit window |
| read-age | READONLY_AGE | | read-only age of comments, days |
| image-proxy.http2https | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images |
| image-proxy.cache-external | IMAGE_PROXY_CACHE_EXTERNAL | `false` | enable caching external images to current image storage |
| emoji | EMOJI | `false` | enable emoji support |
| simple-view | SIMPLE_VIEW | `false` | minimized UI with basic info only |
| port | REMARK_PORT | `8080` | web server port |
| web-root | REMARK_WEB_ROOT | `./web` | web server root directory |
| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit |
| admin-passwd | ADMIN_PASSWD | none (disabled) | password for `admin` basic auth |
| dbg | DEBUG | `false` | debug mode |
* command line parameters are long form `--<key>=value`, i.e. `--site=https://demo.remark42.com`
* _multi_ parameters separated by `,` in the environment or repeated with command line key, like `--site=s1 --site=s2 ...`
* _required_ parameters have to be presented in the environment or provided in command line
##### Deprecated
Following list of command-line options is deprecated and will be removed in 2 minor releases or 1 major release (whichever is closer)
from the version in which they were deprecated. After remark42 version update, please check startup log once for deprecation warnings to avoid
trouble with unrecognized command-line options in the future.
<details>
<summary>deprecated options</summary>
| Command line | Replacement | Environment | Replacement | Default | Description | Deprecation version |
| ------------------ | ------------- | ------------------ | ------------- | ------- | -------------- | ------------------- |
| auth.email.host | smtp.host | AUTH_EMAIL_HOST | SMTP_HOST | | smtp host | 1.5.0 |
| auth.email.port | smtp.port | AUTH_EMAIL_PORT | SMTP_PORT | | smtp port | 1.5.0 |
| auth.email.user | smtp.username | AUTH_EMAIL_USER | SMTP_USERNAME | | smtp user name | 1.5.0 |
| auth.email.passwd | smtp.password | AUTH_EMAIL_PASSWD | SMTP_PASSWORD | | smtp password | 1.5.0 |
| auth.email.tls | smtp.tls | AUTH_EMAIL_TLS | SMTP_TLS | `false` | enable TLS | 1.5.0 |
| auth.email.timeout | smtp.timeout | AUTH_EMAIL_TIMEOUT | SMTP_TIMEOUT | `10s` | smtp timeout | 1.5.0 |
| img-proxy | image-proxy.http2https | IMG_PROXY | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images | 1.5.0 |
</details>
##### Required parameters
Most of the parameters have sane defaults and don't require customization. There are only a few parameters user has to define:
1. `SECRET` - secret key, can be any long and hard-to-guess string.
2. `REMARK_URL` - url pointing to your remark42 server, i.e. `https://demo.remark42.com`
3. At least one pair of `AUTH_<PROVIDER>_CID` and `AUTH_<PROVIDER>_CSEC` defining oauth2 provider(s)
The minimal `docker-compose.yml` has to include all required parameters:
```yaml
version: '2'
services:
remark42:
image: umputun/remark42:latest
restart: always
container_name: "remark42"
environment:
- REMARK_URL=https://demo.remark42.com # url pointing to your remark42 server
- SITE=YOUR_SITE_ID # site ID, same as used for `site_id`, see "Setup on your website"
- SECRET=abcd-123456-xyz-$%^& # secret key
- AUTH_GITHUB_CID=12345667890 # oauth2 client ID
- AUTH_GITHUB_CSEC=abcdefg12345678 # oauth2 client secret
volumes:
- ./var:/srv/var # persistent volume to store all remark42 data
```
#### Quick installation test
To verify if remark has been properly installed, check a demo page at `${REMARK_URL}/web` URL. Make sure to include `remark` site id to `${SITE}` list.
#### Register oauth2 providers
Authentication handled by external providers. You should setup oauth2 for all (or some) of them to allow users to make comments. It is not mandatory to have all of them, but at least one should be correctly configured.
##### Google Auth Provider
1. Create a new project: https://console.developers.google.com/project
1. Choose the new project from the top right project dropdown (only if another project is selected)
1. In the project Dashboard center pane, choose **"API Manager"**
1. In the left Nav pane, choose **"Credentials"**
1. In the center pane, choose **"OAuth consent screen"** tab. Fill in **"Product name shown to users"** and hit save.
1. In the center pane, choose **"Credentials"** tab.
* Open the **"New credentials"** drop down
* Choose **"OAuth client ID"**
* Choose **"Web application"**
* Application name is freeform, choose something appropriate
* Authorized origins is your domain ex: `https://remark42.mysite.com`
* Authorized redirect URIs is the location of oauth2/callback constructed as domain + `/auth/google/callback`, ex: `https://remark42.mysite.com/auth/google/callback`
* Choose **"Create"**
1. Take note of the **Client ID** and **Client Secret**
_instructions for google oauth2 setup borrowed from [oauth2_proxy](https://github.com/bitly/oauth2_proxy)_
##### GitHub Auth Provider
1. Create a new **"OAuth App"**: https://github.com/settings/developers
1. Fill **"Application Name"** and **"Homepage URL"** for your site
1. Under **"Authorization callback URL"** enter the correct url constructed as domain + `/auth/github/callback`. ie `https://remark42.mysite.com/auth/github/callback`
1. Take note of the **Client ID** and **Client Secret**
##### Facebook Auth Provider
1. From https://developers.facebook.com select **"My Apps"** / **"Add a new App"**
1. Set **"Display Name"** and **"Contact email"**
1. Choose **"Facebook Login"** and then **"Web"**
1. Set "Site URL" to your domain, ex: `https://remark42.mysite.com`
1. Under **"Facebook login"** / **"Settings"** fill "Valid OAuth redirect URIs" with your callback url constructed as domain + `/auth/facebook/callback`
1. Select **"App Review"** and turn public flag on. This step may ask you to provide a link to your privacy policy.
##### Twitter Auth Provider
1. Create a new twitter application https://developer.twitter.com/en/apps
1. Fill **App name**, **Description** and **URL** of your site
1. In the field **Callback URLs** enter the correct url of your callback handler e.g. domain + `/auth/twitter/callback`
1. Under **Key and tokens** take note of the **Consumer API Key** and **Consumer API Secret key**. Those will be used as `AUTH_TWITTER_CID` and
`AUTH_TWITTER_CSEC`
##### Yandex Auth Provider
1. Create a new **"OAuth App"**: https://oauth.yandex.com/client/new
1. Fill **"App name"** for your site
1. Under **Platforms** select **"Web services"** and enter **"Callback URI #1"** constructed as domain + `/auth/yandex/callback`. ie `https://remark42.mysite.com/auth/yandex/callback`
1. Select **Permissions**. You need following permissions only from the **"Yandex.Passport API"** section:
* Access to user avatar
* Access to username, first name and surname, gender
1. Fill out the rest of fields if needed
1. Take note of the **ID** and **Password**
For more details refer to [Yandex OAuth](https://tech.yandex.com/oauth/doc/dg/concepts/about-docpage/) and [Yandex.Passport](https://tech.yandex.com/passport/doc/dg/index-docpage/) API documentation.
##### Anonymous Auth Provider
Optionally, anonymous access can be turned on. In this case an extra `anonymous` provider will allow logins without any social login with any name satisfying 2 conditions:
- name should be at least 3 characters long
- name has to start from the letter and contains letters, numbers, underscores and spaces only.
#### Initial import from Disqus
1. Disqus provides an export of all comments on your site in a g-zipped file. This is found in your Moderation panel at Disqus Admin > Setup > Export. The export will be sent into a queue and then emailed to the address associated with your account once it's ready. Direct link to export will be something like `https://<siteud>.disqus.com/admin/discussions/export/`. See [importing-exporting](https://help.disqus.com/customer/portal/articles/1104797-importing-exporting) for more details.
2. Move this file to your remark42 host within `./var` and unzip, i.e. `gunzip <disqus-export-name>.xml.gz`.
3. Run import command - `docker exec -it remark42 import -p disqus -f {disqus-export-name}.xml -s {your site id}`
#### Initial import from WordPress
1. Install WordPress [plugin](https://wordpress.org/plugins/wp-exporter/) to export comments and follow it instructions. The plugin should produce a xml-based file with site content including comments.
2. Move this file to your remark42 host within `./var`
3. Run import command - `docker exec -it remark42 import -p wordpress -f {wordpress-export-name}.xml -s {your site id}`
#### Backup and restore
##### Automatic backups
Remark42 by default makes daily backup files under `${BACKUP_PATH}` (default `./var/backup`). Backups kept up to `${MAX_BACKUP_FILES}` (default 10). Each backup file contains exported and gzipped content, i.e., all comments. At any point, the user can restore such backup and revert all comments to the desirable state. Note: restore procedure cleans the current data store and replaces all comments with comments from the backup file.
For safety and security reasons restore functionality not exposed outside of your server by default. The recommended way to restore from the backup is to use provided `scripts/restore-backup.sh`. It can run inside the container:
`docker exec -it remark42 restore -f {backup-filename.gz} -s {your site id}`
##### Manual backup
In addition to automatic backups user can make a backup manually. This command makes `userbackup-{site id}-{timestamp}.gz` by default.
`docker exec -it remark42 backup -s {your site id}`
##### Restore from backup
Restore will clean all comments first and then will processed with complete import from a given file.
`docker exec -it remark42 restore -f {backup file name} -s {your site id}`
##### Backup format
Backup file is a text file with all exported comments separated by EOL. Each backup record is a valid json with all key/value
unmarshaled from `Comment` struct (see below).
#### Admin users
Admins/moderators should be defined in `docker-compose.yml` as a list of user IDs or passed in the command line.
```
environment:
- ADMIN_SHARED_ID=github_ef0f706a79cc24b17bbbb374cd234a691a034128,github_dae9983158e9e5e127ef2b87a411ef13c891e9e5
```
To get user id just login and click on your username or any other user you want to promote to admins.
It will expand login info and show full user ID.
#### Docker parameters
Two parameters allow customizing Docker container on the system level:
- `APP_UID` - sets UID to run remark42 application in container (default=1001)
- `TIME_ZONE` - sets time zone of remark42 container (default=America/Chicago)
_see [umputun/baseimage](https://github.com/umputun/baseimage) for more details_
example of `docker-compose.yml`:
```yaml
version: '2'
services:
remark42:
image: umputun/remark42:latest
restart: always
container_name: "remark42"
environment:
- APP_UID=2000 # runs remark42 app with non-default UID
- TIME_ZONE=GTC # sets container time to UTC
- REMARK_URL=https://demo.remark42.com # url pointing to your remark42 server
- SITE=YOUR_SITE_ID # site ID, same as used for `site_id`, see "Setup on your website"
- SECRET=abcd-123456-xyz-$%^& # secret key
- AUTH_GITHUB_CID=12345667890 # oauth2 client ID
- AUTH_GITHUB_CSEC=abcdefg12345678 # oauth2 client secret
volumes:
- ./var:/srv/var # persistent volume to store all remark42 data
```
### Setup on your website
#### Comments
It's a main widget which renders list of comments.
Add this snippet to the bottom of web page:
```html
<script>
var remark_config = {
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
site_id: 'YOUR_SITE_ID',
components: ['embed'], // optional param; which components to load. default to ["embed"]
// to load all components define components as ['embed', 'last-comments', 'counter']
// available component are:
// - 'embed': basic comments widget
// - 'last-comments': last comments widget, see `Last Comments` section below
// - 'counter': counter widget, see `Counter` section below
url: 'PAGE_URL', // optional param; if it isn't defined
// `window.location.origin + window.location.pathname` will be used,
//
// Note that if you use query parameters as significant part of url
// (the one that actually changes content on page)
// you will have to configure url manually to keep query params, as
// `window.location.origin + window.location.pathname` doesn't contain query params and
// hash. For example default url for `https://example/com/example-post?id=1#hash`
// would be `https://example/com/example-post`.
//
// The problem with query params is that they often contain useless params added by
// various trackers (utm params) and doesn't have defined order, so Remark treats differently
// all this examples:
// https://example.com/?postid=1&date=2007-02-11
// https://example.com/?date=2007-02-11&postid=1
// https://example.com/?date=2007-02-11&postid=1&utm_source=google
//
// If you deal with query parameters make sure you pass only significant part of it
// in well defined order
max_shown_comments: 10, // optional param; if it isn't defined default value (15) will be used
theme: 'dark', // optional param; if it isn't defined default value ('light') will be used
page_title: 'Moving to Remark42', // optional param; if it isn't defined `document.title` will be used
locale: 'en' // set up locale and language, if it isn't defined default value ('en') will be used
};
(function(c) {
for(var i = 0; i < c.length; i++){
var d = document, s = d.createElement('script');
s.src = remark_config.host + '/web/' +c[i] +'.js';
s.defer = true;
(d.head || d.body).appendChild(s);
}
})(remark_config.components || ['embed']);
</script>
```
And then add this node in the place where you want to see Remark42 widget:
```html
<div id="remark42"></div>
```
After that widget will be rendered inside this node.
##### Themes
Right now Remark has two themes: light and dark.
You can pick one using configuration object,
but there is also a possibility to switch between themes in runtime.
For this purpose Remark adds to `window` object named `REMARK42`,
which contains function `changeTheme`.
Just call this function and pass a name of the theme that you want to turn on:
```js
window.REMARK42.changeTheme('light');
```
##### Locales
Right now Remark is translated to en, ru (partially), de, and fi languages.
You can pick one using [configuration object](#setup-on-your-website).
Do you want translate remark42 to other locale? Please see [this documentation](https://github.com/umputun/remark42/blob/master/docs/translation.md) for details.
#### Last comments
It's a widget which renders list of last comments from your site.
Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `last-comments` in `components` list:
```html
<script>
var remark_config = {
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
site_id: 'YOUR_SITE_ID',
components: ['last-comments']
};
(function(c) {
for(var i = 0; i < c.length; i++){
var d = document, s = d.createElement('script');
s.src = remark_config.host + '/web/' +c[i] +'.js';
s.defer = true;
(d.head || d.body).appendChild(s);
}
})(remark_config.components || ['embed']);
</script>
```
And then add this node in the place where you want to see last comments widget:
```html
<div class="remark42__last-comments" data-max="50"></div>
```
`data-max` sets the max amount of comments (default: `15`).
#### Counter
It's a widget which renders a number of comments for the specified page.
Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `counter` in `components` list:
```html
<script>
var remark_config = {
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
site_id: 'YOUR_SITE_ID',
components: ['counter']
};
(function(c) {
for(var i = 0; i < c.length; i++){
var d = document, s = d.createElement('script');
s.src = remark_config.host + '/web/' +c[i] +'.js';
s.defer = true;
(d.head || d.body).appendChild(s);
}
})(remark_config.components || ['embed']);
</script>
```
And then add a node like this in the place where you want to see a number of comments:
```html
<span class="remark42__counter" data-url="https://domain.com/path/to/article/"></span>
```
You can use as many nodes like this as you need to.
The script will found all them by the class `remark__counter`,
and it will use `data-url` attribute to define the page with comments.
Also script can use `url` property from `remark_config` object, or `window.location.origin + window.location.pathname` if nothing else is defined.
## Build from the source
- to build Docker container - `make docker`. This command will produce container `umputun/remark42`.
- to build a single binary for direct execution - `make OS=<linux|windows|darwin> ARCH=<amd64|386>`. This step will produce executable
`remark42` file with everything embedded.
## Development
You can use fully functional local version to develop and test both frontend & backend. It requires at least 2GB RAM or swap enabled
To bring it up run:
```bash
# if you mainly work on backend
docker-compose -f compose-dev-backend.yml build
docker-compose -f compose-dev-backend.yml up
# if you mainly work on frontend
docker-compose -f compose-dev-frontend.yml build
docker-compose -f compose-dev-frontend.yml up
```
It starts Remark42 on `127.0.0.1:8080` and adds local OAuth2 provider “Dev”.
To access UI demo page go to `127.0.0.1:8080/web`.
By default, you would be logged in as `dev_user` which defined as admin.
You can tweak any of [supported parameters](#Parameters) in corresponded yml file.
Backend Docker Compose config by default skips running frontend related tests.
Frontend Docker Compose config by default skips running backend related tests and sets `NODE_ENV=development` for frontend build.
### Backend development
In order to run backend locally (development mode, without Docker) you have to have the latest stable `go` toolchain [installed](https://golang.org/doc/install).
To run backend - `cd backend; go run app/main.go server --dbg --secret=12345 --url=http://127.0.0.1:8080 --admin-passwd=password --site=remark`
It stars backend service with embedded bolt store on port `8080` with basic auth, allowing to authenticate and run requests directly, like this:
`HTTP http://admin:password@127.0.0.1:8080/api/v1/find?site=remark&sort=-active&format=tree&url=http://127.0.0.1:8080`
### Frontend development
#### Developer guide
Frontend guide can be found here: [./frontend/Readme.md](./frontend/Readme.md)
#### Build
You should have at least 2GB RAM or swap enabled for building
* install [Node.js 12.11](https://nodejs.org/en/) or higher;
* install [NPM 6.13.4](https://www.npmjs.com/package/npm);
* run `npm install` inside `./frontend`;
* run `npm run build` there;
* result files will be saved in `./frontend/public`.
**Note** Running `npm install` will set up precommit hooks into your git repository.
It used to reformat your frontend code using `prettier` and lint with `eslint` and `stylelint` before every commit.
#### Devserver
For local development mode with Hot Reloading use `npm start` instead of `npm run build`.
In this case `webpack` will serve files using `webpack-dev-server` on `localhost:9000`.
By visiting `127.0.0.1:9000/web` you will get a page with main comments widget
communicating with demo server backend running on `https://demo.remark42.com`.
But you will not be able to login with any oauth providers due to security reasons.
You can attach to locally running backend by providing `REMARK_URL` environment variable.
```sh
npx cross-env REMARK_URL=http://127.0.0.1:8080 npm start
```
The best way for start local developer enviroment:
```sh
cp compose-dev-frontend.yml compose-private-frontend.yml
docker-compose -f compose-private-frontend.yml up --build
cd frontend
npm run dev
```
Developer build running by `webpack-dev-server` supports devtools for [React](https://github.com/facebook/react-devtools) and
[Redux](https://github.com/zalmoxisus/redux-devtools-extension).
## API
### Authorization
* `GET /auth/{provider}/login?from=http://url&site=site_id&session=1` - perform "social" login with one of supported providers and redirect to `url`. Presence of `session` (any non-zero value) change the default cookie expiration and makes them session-only.
* `GET /auth/logout` - logout
```go
type User struct {
Name string `json:"name"`
ID string `json:"id"`
Picture string `json:"picture"`
Admin bool `json:"admin"`
Blocked bool `json:"block"`
Verified bool `json:"verified"`
}
```
_currently supported providers are `google`, `facebook`, `github` and `yandex`_
### Commenting
* `POST /api/v1/comment` - add a comment. _auth required_
```go
type Comment struct {
ID string `json:"id"` // comment ID, read only
ParentID string `json:"pid"` // parent ID
Text string `json:"text"` // comment text, after md processing
Orig string `json:"orig"` // original comment text
User User `json:"user"` // user info, read only
Locator Locator `json:"locator"` // post locator
Score int `json:"score"` // comment score, read only
Vote int `json:"vote"` // vote for the current user, -1/1/0.
Controversy float64 `json:"controversy,omitempty"` // comment controversy, read only
Timestamp time.Time `json:"time"` // time stamp, read only
Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response
Pin bool `json:"pin"` // pinned status, read only
Delete bool `json:"delete"` // delete status, read only
PostTitle string `json:"title"` // post title
}
type Locator struct {
SiteID string `json:"site"` // site id
URL string `json:"url"` // post url
}
type Edit struct {
Timestamp time.Time `json:"time" bson:"time"`
Summary string `json:"summary"`
}
```
* `POST /api/v1/preview` - preview comment in html. Body is `Comment` to render
* `GET /api/v1/find?site=site-id&url=post-url&sort=fld&format=tree|plain` - find all comments for given post
This is the primary call used by UI to show comments for given post. It can return comments in two formats - `plain` and `tree`.
In plain format result will be sorted list of `Comment`. In tree format this is going to be tree-like object with this structure:
```go
type Tree struct {
Nodes []Node `json:"comments"`
Info store.PostInfo `json:"info,omitempty"`
}
type Node struct {
Comment store.Comment `json:"comment"`
Replies []Node `json:"replies,omitempty"`
}
```
Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i.e. `-time`. For `tree` mode sort will be applied to top-level comments only and all replies always sorted by time.
* `PUT /api/v1/comment/{id}?site=site-id&url=post-url` - edit comment, allowed once in `EDIT_TIME` minutes since creation. Body is `EditRequest` json
```go
type EditRequest struct {
Text string `json:"text"` // updated text
Summary string `json:"summary"` // optional, summary of the edit
Delete bool `json:"delete"` // delete flag
}{}
```
* `GET /api/v1/last/{max}?site=site-id&since=ts-msec` - get up to `{max}` last comments, `since` (epoch time, milliseconds) is optional
* `GET /api/v1/id/{id}?site=site-id` - get comment by `comment id`
* `GET /api/v1/comments?site=site-id&user=id&limit=N` - get comment by `user id`, returns `response` object
```go
type response struct {
Comments []store.Comment `json:"comments"`
Count int `json:"count"`
}{}
```
* `GET /api/v1/count?site=site-id&url=post-url` - get comment's count for `{url}`
* `POST /api/v1/count?site=siteID` - get number of comments for posts from post body (list of post IDs)
* `GET /api/v1/list?site=site-id&limit=5&skip=2` - list commented posts, returns array or `PostInfo`, limit=0 will return all posts
```go
type PostInfo struct {
URL string `json:"url"`
Count int `json:"count"`
ReadOnly bool `json:"read_only,omitempty"`
FirstTS time.Time `json:"first_time,omitempty"`
LastTS time.Time `json:"last_time,omitempty"`
}
```
* `GET /api/v1/user` - get user info, _auth required_
* `PUT /api/v1/vote/{id}?site=site-id&url=post-url&vote=1` - vote for comment. `vote`=1 will increase score, -1 decrease. _auth required_
* `GET /api/v1/userdata?site=site-id` - export all user data to gz stream _auth required_
* `POST /api/v1/deleteme?site=site-id` - request deletion of user data. _auth required_
* `GET /api/v1/config?site=site-id` - returns configuration (parameters) for given site
```go
type Config struct {
Version string `json:"version"`
EditDuration int `json:"edit_duration"`
MaxCommentSize int `json:"max_comment_size"`
Admins []string `json:"admins"`
AdminEmail string `json:"admin_email"`
Auth []string `json:"auth_providers"`
LowScore int `json:"low_score"`
CriticalScore int `json:"critical_score"`
PositiveScore bool `json:"positive_score"`
ReadOnlyAge int `json:"readonly_age"`
MaxImageSize int `json:"max_image_size"`
EmojiEnabled bool `json:"emoji_enabled"`
}
```
* `GET /api/v1/info?site=site-idd&url=post-url` - returns `PostInfo` for site and url
### Streaming API
Streaming API provide server-sent events for post updates as well as site update
* `GET /api/v1/stream/info?site=site-idd&url=post-url&since=unix_ts_msec` - returns stream (`event: info`) with `PostInfo` records for the site and url. `since` is optional
* `GET /api/v1/stream/last?site=site-id&since=unix_ts_msec` - returns updates stream (`event: last`) with comments for the site, `since` is optional
<details><summary>response example</summary>
```
data: {"url":"https://radio-t.com/blah1","count":2,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.142872-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":3,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.157709-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":4,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.172991-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":5,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.188429-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":6,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.204742-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":7,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.220692-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":8,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.23817-05:00"}
event: info
data: {"url":"https://radio-t.com/blah1","count":9,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.254669-05:00"}
```
</details>
### RSS feeds
* `GET /api/v1/rss/post?site=site-id&url=post-url` - rss feed for a post
* `GET /api/v1/rss/site?site=site-id` - rss feed for given site
* `GET /api/v1/rss/reply?site=site-id&user=user-id` - rss feed for replies to user's comments
### Images management
* `GET /api/v1/picture/{user}/{id}` - load stored image
* `POST /api/v1/picture` - upload and store image, uses post form with `FormFile("file")`. returns `{"id": user/imgid}` _auth required_
_returned id should be appended to load image url on caller side_
### Email subscription
* `GET /api/v1/email?site=site-id` - get user's email, _auth required_
* `POST /api/v1/email/subscribe?site=site-id&address=user@example.org` - makes confirmation token and sends it to user over email, _auth required_
Trying to subscribe same email second time will return response code `409 Conflict` and explaining error message.
* `POST /api/v1/email/confirm?site=site-id&tkn=token` - uses provided token parameter to set email for the user, _auth required_
Setting email subscribe user for all first-level replies to his messages.
* `DELETE /api/v1/email?site=siteID` - removes user's email, _auth required_
### Admin
* `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`.
* `PUT /api/v1/admin/user/{userid}?site=site-id&block=1&ttl=7d` - block or unblock user with optional ttl (default=permanent)
* `GET api/v1/admin/blocked&site=site-id` - list of blocked user ids
```go
type BlockedUser struct {
ID string `json:"id"`
Name string `json:"name"`
Until time.Time `json:"time"`
}
```
* `GET /api/v1/admin/export?site=site-id&mode=[stream|file]` - export all comments to json stream or gz file.
* `POST /api/v1/admin/import?site=site-id` - import comments from the backup, uses post body.
* `POST /api/v1/admin/import/form?site=site-id` - import comments from the backup, user post form.
* `POST /api/v1/admin/remap?site=site-id` - remap comments to different URLs. Expect list of "from-url new-url" pairs separated by \n.
From-url and new-url parts separated by space. If urls end with asterisk (*) it means matching by prefix. Remap procedure based on
export/import chain so make backup first.
```
http://oldsite.com* https://newsite.com*
http://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1
```
* `GET /api/v1/admin/wait?site=site-id` - wait for completion for any async migration ops (import or remap).
* `PUT /api/v1/admin/pin/{id}?site=site-id&url=post-url&pin=1` - pin or unpin comment.
* `GET /api/v1/admin/user/{userid}?site=site-id` - get user's info.
* `DELETE /api/v1/admin/user/{userid}?site=site-id` - delete all user's comments.
* `PUT /api/v1/admin/readonly?site=site-id&url=post-url&ro=1` - set read-only status
* `PUT /api/v1/admin/verify/{userid}?site=site-id&verified=1` - set verified status
* `GET /api/v1/admin/deleteme?token=token` - process deleteme user's request
_all admin calls require auth and admin privilege_
## Privacy
* Remark42 is trying to be very sensitive to any private or semi-private information.
* Authentication requesting the minimal possible scope from authentication providers. All extra information returned by them dropped immediately and not stored in any form.
* Generally, remark42 keeps user id, username and avatar link only. None of these fields exposed directly - id and name hashed, avatar proxied.
* There is no tracking of any sort.
* Login mechanic uses JWT stored in a cookie (httpOnly, secured). The second cookie (XSRF_TOKEN) is a random id preventing CSRF.
* There is no cross-site login, i.e., user's behavior can't be analyzed across independent sites running remark42.
* There are no third-party analytic services involved.
* User can request all information remark42 knows about and export to gz file.
* Supported complete cleanup of all information related to user's activity.
* Cookie lifespan can be restricted to session-only.
* All potentially sensitive data stored by remark42 hashed and encrypted.
## Technical details
* Data stored in [boltdb](https://github.com/coreos/bbolt) (embedded key/value database) files under `STORE_BOLT_PATH`
* Each site stored in a separate boltbd file.
* In order to migrate/move remark42 to another host boltbd files as well as avatars directory `AVATAR_FS_PATH` should be transferred. Optionally, boltdb can be used to store avatars as well.
* Automatic backup process runs every 24h and exports all content in json-like format to `backup-remark-YYYYMMDD.gz`.
* Authentication implemented with [go-pkgz/auth](https://github.com/go-pkgz/auth) stored in a cookie. It uses HttpOnly, secure cookies.
* All heavy REST calls cached internally in LRU cache limited by `CACHE_MAX_ITEMS` and `CACHE_MAX_SIZE` with [go-pkgz/rest](https://github.com/go-pkgz/rest)
* User's activity throttled globally (up to 1000 simultaneous requests) and limited locally (per user, usually up to 10 req/sec)
* Request timeout set to 60sec
* Admin authentication (`--admin-password` set) allows to hit remark42 API without social login and with admin privileges. Adds basic-auth for username: `admin`, password: `${ADMIN_PASSWD}`.
* User can vote for the comment multiple times but only to change the vote. Double-voting not allowed.
* User can edit comments in 5 mins (configurable) window after creation.
* User ID hashed and prefixed by oauth provider name to avoid collisions and potential abuse.
* All avatars resized and cached locally to prevent rate limiters from oauth providers, part of [go-pkgz/auth](https://github.com/go-pkgz/auth) functionality.
* Images can be proxied (`IMAGE_PROXY_HTTP2HTTPS=true`) to prevent mixed http/https.
* All images can be proxied and saved (`IMAGE_PROXY_CACHE_EXTERNAL=true`) instead of serving from original location. Beware, images which are posted with this parameter enabled will be served from proxy even after it will be disabled.
* Docker build uses [publicly available](https://github.com/umputun/baseimage) base images.
* [A Helm chart for Remark42 on Kubernetes](https://github.com/groundhog2k/helm-charts/tree/master/charts/remark42)
* [django-remark42](https://github.com/andrewp-as-is/django-remark42.py)
+15
View File
@@ -0,0 +1,15 @@
# Security Policy
## Supported Versions
We release patches for security vulnerabilities.
| Version | Supported |
| ------- | ------------------ |
| current | :white_check_mark: |
| >=1.6.x | :white_check_mark: |
| <1.5.x | :x: |
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to umputun@gmail.com. You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
+8 -5
View File
@@ -1,4 +1,5 @@
run:
timeout: 5m
output:
format: tab
skip-dirs:
@@ -25,11 +26,15 @@ linters-settings:
- experimental
disabled-checks:
- wrapperFunc
# TODO: feel free to remove these excludes and fix the code
- hugeParam
- rangeValCopy
linters:
enable:
- bodyclose
- megacheck
- golint
- revive
- govet
- unconvert
- megacheck
@@ -46,7 +51,8 @@ linters:
- varcheck
- stylecheck
- gochecknoinits
- scopelint
- exportloopref
- gocritic
- nakedret
- gosimple
- prealloc
@@ -66,6 +72,3 @@ issues:
- gosec
- dupl
exclude-use-default: false
service:
golangci-lint-version: 1.23.x
-3
View File
@@ -1,8 +1,5 @@
FROM umputun/baseimage:buildgo-latest as build-backend
#ADD . /build/memory_store
#WORKDIR /build/memory_store
ADD backend /build/backend
WORKDIR /build/backend/_example/memory_store
+3 -4
View File
@@ -1,6 +1,6 @@
# sample store implementation
# sample store implementation
`memory_store` illustrates how to make a custom storage plugin for remark42.
`memory_store` illustrates how to make a custom storage plugin for remark42.
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run:
@@ -9,5 +9,4 @@ In order to run remark42 with memory_store copy provided `compose-dev-memstore.y
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/remark/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.
@@ -10,7 +10,7 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/admin"
)
// MemAdmin implements admin.Store with memory backend
@@ -35,7 +35,7 @@ func NewMemAdminStore(key string) *MemAdmin {
}
// Key executes find by siteID and returns substructure with secret key
func (m *MemAdmin) Key() (key string, err error) {
func (m *MemAdmin) Key(_ string) (key string, err error) {
return m.key, nil
}
@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/admin"
)
func TestMemAdmin_Get(t *testing.T) {
@@ -31,7 +31,7 @@ func TestMemAdmin_Get(t *testing.T) {
email, err := ms.Email("site1")
assert.NoError(t, err)
assert.Equal(t, "e1", email)
key, err := ms.Key()
key, err := ms.Key("any")
assert.NoError(t, err)
assert.Equal(t, "secret", key)
@@ -41,7 +41,7 @@ func TestMemAdmin_Get(t *testing.T) {
email, err = ms.Email("site2")
assert.NoError(t, err)
assert.Equal(t, "e2", email)
key, err = ms.Key()
key, err = ms.Key("any")
assert.NoError(t, err)
assert.Equal(t, "secret", key)
+46 -37
View File
@@ -14,8 +14,8 @@ import (
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
const lastLimit = 1000
@@ -25,7 +25,7 @@ type MemData struct {
posts map[string][]store.Comment // key is siteID
metaUsers map[string]metaUser // key is userID
metaPosts map[store.Locator]metaPost // key is post's locator
sync.RWMutex
mu sync.RWMutex
}
type metaPost struct {
@@ -61,8 +61,8 @@ func (m *MemData) Create(comment store.Comment) (commentID string, err error) {
return "", errors.Errorf("post %s is read-only", comment.Locator.URL)
}
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
comments := m.posts[comment.Locator.SiteID]
for _, c := range comments { // don't allow duplicated IDs
if c.ID == comment.ID {
@@ -76,8 +76,8 @@ func (m *MemData) Create(comment store.Comment) (commentID string, err error) {
// Find returns all comments for post and sorts results
func (m *MemData) Find(req engine.FindRequest) (comments []store.Comment, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
comments = []store.Comment{}
@@ -132,22 +132,22 @@ func (m *MemData) Find(req engine.FindRequest) (comments []store.Comment, err er
// Get returns comment for locator.URL and commentID string
func (m *MemData) Get(req engine.GetRequest) (comment store.Comment, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
return m.get(req.Locator, req.CommentID)
}
// Update updates comment for locator.URL with mutable part of comment
func (m *MemData) Update(comment store.Comment) error {
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
return m.updateComment(comment)
}
// Count returns number of comments for post or user
func (m *MemData) Count(req engine.FindRequest) (count int, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
switch {
case req.Locator.URL != "": // comment's count for post
@@ -167,8 +167,8 @@ func (m *MemData) Count(req engine.FindRequest) (count int, err error) {
// Info get post(s) meta info
func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
res = []store.PostInfo{}
if req.Locator.URL != "" { // post info
@@ -240,8 +240,8 @@ func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error)
// Flag sets and gets flag values
func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
if req.Update == engine.FlagNonSet { // read flag value, no update requested
return m.checkFlag(req), nil
@@ -253,8 +253,8 @@ func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
// ListFlags get list of flagged keys, like blocked & verified user
// works for full locator (post flags) or with userID
func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err error) {
m.RLock()
defer m.RUnlock()
m.mu.RLock()
defer m.mu.RUnlock()
res = []interface{}{}
@@ -285,13 +285,13 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
// and all site's details listing under the same function (and not to extend engine interface by two separate functions).
func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
switch req.Detail {
case engine.UserEmail:
case engine.UserEmail, engine.UserTelegram:
if req.UserID == "" {
return nil, errors.New("userid cannot be empty in request for single detail")
}
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
if req.Update == "" { // read detail value, no update requested
return m.getUserDetail(req)
@@ -302,8 +302,8 @@ func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailE
// list of all details returned in case request is a read request
// (Update is not set) and does not have UserID or Detail set
if req.Update == "" && req.UserID == "" { // read list of all details
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
return m.listDetails(req.Locator)
}
return nil, errors.New("unsupported request with userdetail all")
@@ -315,8 +315,8 @@ func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailE
// Delete post(s), user, comment, user details, or everything
func (m *MemData) Delete(req engine.DeleteRequest) error {
m.Lock()
defer m.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
switch {
case req.UserDetail != "": // delete user detail
@@ -443,6 +443,8 @@ func (m *MemData) getUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
switch req.Detail {
case engine.UserEmail:
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}, nil
case engine.UserTelegram:
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}, nil
}
}
@@ -473,6 +475,10 @@ func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
entry.Details.Email = req.Update
m.metaUsers[req.UserID] = entry
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}, nil
case engine.UserTelegram:
entry.Details.Telegram = req.Update
m.metaUsers[req.UserID] = entry
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}, nil
}
return []engine.UserDetailEntry{}, nil
@@ -509,6 +515,8 @@ func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDet
switch userDetail {
case engine.UserEmail:
entry.Details.Email = ""
case engine.UserTelegram:
entry.Details.Telegram = ""
case engine.AllUserDetails:
entry.Details = engine.UserDetailEntry{UserID: userID}
}
@@ -535,18 +543,19 @@ func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error
func (m *MemData) updateComment(comment store.Comment) error {
comments := m.posts[comment.Locator.SiteID]
for i, c := range comments {
if c.ID == comment.ID && c.Locator == comment.Locator {
c.Text = comment.Text
c.Orig = comment.Orig
c.Score = comment.Score
c.Votes = comment.Votes
c.Pin = comment.Pin
c.Deleted = comment.Deleted
c.User = comment.User
comments[i] = c
m.posts[comment.Locator.SiteID] = comments
return nil
if c.ID != comment.ID || c.Locator != comment.Locator {
continue
}
c.Text = comment.Text
c.Orig = comment.Orig
c.Score = comment.Score
c.Votes = comment.Votes
c.Pin = comment.Pin
c.Deleted = comment.Deleted
c.User = comment.User
comments[i] = c
m.posts[comment.Locator.SiteID] = comments
return nil
}
return errors.New("not found")
}
@@ -15,8 +15,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
func TestMemData_CreateAndFind(t *testing.T) {
@@ -658,12 +658,40 @@ func TestMemData_DeleteAll(t *testing.T) {
assert.Equal(t, 0, len(comments), "nothing left")
}
func TestMemData_UserDetailAll(t *testing.T) {
b := prepMem(t)
val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails})
require.NoError(t, err)
require.Nil(t, val)
}
func TestMemData_UserDetailErrors(t *testing.T) {
b := prepMem(t)
val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.UserEmail, Update: "value1"})
require.EqualError(t, err, "userid cannot be empty in request for single detail")
require.Nil(t, val)
val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails, Update: "value1"})
require.EqualError(t, err, "unsupported request with userdetail all")
require.Nil(t, val)
val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: "bad"})
require.EqualError(t, err, "unsupported detail \"bad\"")
require.Nil(t, val)
}
func TestMemData_DeleteUserDetail(t *testing.T) {
var (
createUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"}
readUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail}
emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}}
emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}}
createEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"}
readEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail}
createTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram, Update: "value1"}
readTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram}
emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}}
emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}}
telegramSet = []engine.UserDetailEntry{{UserID: "user1", Telegram: "value1"}}
telegramUnset = []engine.UserDetailEntry{{UserID: "user1", Telegram: ""}}
)
b := prepMem(t)
@@ -674,15 +702,21 @@ func TestMemData_DeleteUserDetail(t *testing.T) {
expected []engine.UserDetailEntry
}{
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: createUser, expected: emailSet},
detailReq: createEmailUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: readUser, expected: emailSet},
detailReq: readEmailUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: readUser, expected: emailUnset},
detailReq: readEmailUser, expected: emailUnset},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram},
detailReq: createTelegramUser, expected: telegramSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserTelegram},
detailReq: readTelegramUser, expected: telegramSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram},
detailReq: readTelegramUser, expected: telegramUnset},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
detailReq: createUser, expected: emailSet},
detailReq: createEmailUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
detailReq: readUser, expected: emailUnset},
detailReq: readEmailUser, expected: emailUnset},
}
for i, x := range testData {
+27 -16
View File
@@ -14,7 +14,7 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/image"
)
// MemImage implements image.Store with memory backend
@@ -22,7 +22,7 @@ type MemImage struct {
imagesStaging map[string][]byte
images map[string][]byte
insertTime map[string]time.Time
sync.RWMutex
mu sync.RWMutex
}
// NewMemImageStore makes admin Store in memory.
@@ -37,22 +37,33 @@ func NewMemImageStore() *MemImage {
// Save stores image with passed id to staging
func (m *MemImage) Save(id string, img []byte) error {
m.Lock()
m.mu.Lock()
m.imagesStaging[id] = img
m.insertTime[id] = time.Now()
m.Unlock()
m.mu.Unlock()
return nil
}
// ResetCleanupTimer resets cleanup timer for the image
func (m *MemImage) ResetCleanupTimer(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.insertTime[id]; ok {
m.insertTime[id] = time.Now()
return nil
}
return errors.Errorf("image %s not found", id)
}
// Load image by ID
func (m *MemImage) Load(id string) ([]byte, error) {
m.RLock()
m.mu.RLock()
img, ok := m.images[id]
if !ok {
img, ok = m.imagesStaging[id]
}
m.RUnlock()
m.mu.RUnlock()
if !ok {
return nil, errors.Errorf("image %s not found", id)
}
@@ -61,16 +72,16 @@ func (m *MemImage) Load(id string) ([]byte, error) {
// Commit moves image from staging to permanent
func (m *MemImage) Commit(id string) error {
m.RLock()
m.mu.RLock()
img, ok := m.imagesStaging[id]
m.RUnlock()
m.mu.RUnlock()
if !ok {
return errors.Errorf("failed to commit %s, not found in staging", id)
}
m.Lock()
m.mu.Lock()
m.images[id] = img
m.Unlock()
m.mu.Unlock()
return nil
}
@@ -79,7 +90,7 @@ func (m *MemImage) Commit(id string) error {
func (m *MemImage) Cleanup(_ context.Context, ttl time.Duration) error {
var idsToRemove []string
m.RLock()
m.mu.RLock()
for id, t := range m.insertTime {
age := time.Since(t)
if age > ttl {
@@ -87,27 +98,27 @@ func (m *MemImage) Cleanup(_ context.Context, ttl time.Duration) error {
idsToRemove = append(idsToRemove, id)
}
}
m.RUnlock()
m.mu.RUnlock()
m.Lock()
m.mu.Lock()
for _, id := range idsToRemove {
delete(m.insertTime, id)
delete(m.imagesStaging, id)
}
m.Unlock()
m.mu.Unlock()
return nil
}
// Info returns meta information about storage
func (m *MemImage) Info() (image.StoreInfo, error) {
var ts time.Time
m.RLock()
m.mu.RLock()
for _, t := range m.insertTime {
if ts.IsZero() || t.Before(ts) {
ts = t
}
}
m.RUnlock()
m.mu.RUnlock()
return image.StoreInfo{FirstStagingImageTS: ts}, nil
}
@@ -10,7 +10,6 @@ import (
"context"
"encoding/base64"
"io"
"io/ioutil"
"strings"
"testing"
"time"
@@ -43,7 +42,7 @@ func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, string
func TestMemImage_LoadAfterSave(t *testing.T) {
svc := NewMemImageStore()
gopher, err := ioutil.ReadAll(gopherPNG())
gopher, err := io.ReadAll(gopherPNG())
assert.NoError(t, err)
img, err := svc.Load("test_id")
@@ -58,6 +57,8 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, gopher, img)
svc.ResetCleanupTimer(id)
err = svc.Commit(id)
assert.NoError(t, err)
@@ -83,7 +84,7 @@ func TestMemImage_Cleanup(t *testing.T) {
func TestMemImage_Info(t *testing.T) {
svc := NewMemImageStore()
gopher, err := ioutil.ReadAll(gopherPNG())
gopher, err := io.ReadAll(gopherPNG())
assert.NoError(t, err)
// get info on empty storage, should be zero
@@ -1,10 +1,9 @@
# compose file demonstrating custom storage use. The memory_store (see backend/_example/memory_store) starts
# in a separate container and remark42 communicates to mem_store.r42 via STORE_RPC_API url
version: '2'
version: "2"
services:
remark42:
build:
context: ../../..
+7 -7
View File
@@ -1,14 +1,14 @@
module github.com/umputun/remark/memory_store
module github.com/umputun/remark42/memory_store
go 1.14
go 1.16
require (
github.com/go-pkgz/jrpc v0.1.0
github.com/go-pkgz/lgr v0.7.0
github.com/go-pkgz/jrpc v0.2.0
github.com/go-pkgz/lgr v0.10.4
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.5.1
github.com/stretchr/testify v1.7.0
github.com/umputun/go-flags v1.5.1
github.com/umputun/remark/backend v1.5.0
github.com/umputun/remark42/backend v1.8.1
)
replace github.com/umputun/remark/backend => ../../
replace github.com/umputun/remark42/backend => ../../
+626 -109
View File
@@ -1,269 +1,786 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.40.0/go.mod h1:Tk58MuI9rbLMKlAjeO/bDnteAx7tX2gJIXw4T5Jwlro=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Depado/bfchroma v1.2.0 h1:NyYPFVhWvq8S2ts6Ok4kwXVE3TEO5fof+9ZOKbBJQUo=
github.com/Depado/bfchroma v1.2.0/go.mod h1:U3RJUYwWVJrZRaJQyfS+wuxBApSTR/BC37PhAI+Ydps=
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Depado/bfchroma v1.3.0 h1:zz14vpvySU6S0CL6yGPr1vkFevQecIt8dJdCsMS2JpM=
github.com/Depado/bfchroma v1.3.0/go.mod h1:c0bFk0tFmT+clD3TIGurjWCfD/QV8/EebfM3JGr+98M=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.6.0 h1:gcvXlpe0/NoQP3BvneRfgcauLIJDw9VblkoFwZ5XGFs=
github.com/alecthomas/chroma v0.6.0/go.mod h1:MmozekIi2rfQSzDcdEZ2BoJ9Pxs/7uc2Y4Boh+hIeZo=
github.com/alecthomas/chroma v0.7.2 h1:B76NU/zbQYIUhUowbi4fmvREmDUJLsUzKWTZmQd3ABY=
github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/chroma v0.7.3/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
github.com/alecthomas/chroma v0.9.4 h1:YL7sOAE3p8HS96T9km7RgvmsZIctqbK1qJ0b7hzed44=
github.com/alecthomas/chroma v0.9.4/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 h1:GDQdwm/gAcJcLAKQQZGOJ4knlw+7rfEQQcmwTbt4p5E=
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/repr v0.0.0-20200325044227-4184120f674c/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/alicebob/miniredis/v2 v2.11.4/go.mod h1:VL3UDEfAH59bSa7MuHMuFToxkqyHh69s/WUbYlOAuyg=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk=
github.com/dghubble/oauth1 v0.7.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7zjmGO20HCZtF4M=
github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY=
github.com/didip/tollbooth_chi v0.0.0-20170928041846-6ab5f3083f3d h1:vs5Nf6IE0N/PwGJ8//zRed4gpCdcr99K2HzX7RuLOQ8=
github.com/didip/tollbooth_chi v0.0.0-20170928041846-6ab5f3083f3d/go.mod h1:YWyIfq3y4ArRfWZ9XksmuusP+7Mad+T0iFZ0kv0XG/M=
github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/didip/tollbooth/v6 v6.0.1/go.mod h1:j2pKs+JQ5PvU/K4jFnrnwntrmfUbYLJE5oSdxR37FD0=
github.com/didip/tollbooth/v6 v6.1.1 h1:Nt7PvWLa9Y94OrykXsFNBinVRQIu8xdy4avpl99Dc1M=
github.com/didip/tollbooth/v6 v6.1.1/go.mod h1:xjcse6CTHCLuOkzsWrEgdy9WPJFv+p/x6v+MyfP+O9s=
github.com/didip/tollbooth_chi v0.0.0-20200524181329-8b84cd7183d9/go.mod h1:YWyIfq3y4ArRfWZ9XksmuusP+7Mad+T0iFZ0kv0XG/M=
github.com/didip/tollbooth_chi v0.0.0-20200828173446-a7173453ea21 h1:x7YpwKSBIBcKe9I3aTNOqgSyJ6QKDdtOxnEkxBTsi9w=
github.com/didip/tollbooth_chi v0.0.0-20200828173446-a7173453ea21/go.mod h1:0ZVa6kSzS011nfTC1rELyxK4tjVf6vqBnOv7oY2KlsA=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v0.0.0-20180803094507-bdde30871313/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/gavv/monotime v0.0.0-20171021193802-6f8212e8d10d/go.mod h1:vmp8DIyckQMXOPl0AQVHt+7n5h7Gb7hS6CUydiV8QeA=
github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.1.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.0.5/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.0/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
github.com/go-pkgz/auth v0.10.1/go.mod h1:wxyQqc0UUP1jT4l6zk1r6XPcVdcgIzW2OiQ8hBEHd64=
github.com/go-pkgz/jrpc v0.1.0 h1:hNg/IyfEqJcSWOKkuHw0ZwcuGc9TDp7QZREsD2ycmiM=
github.com/go-pkgz/jrpc v0.1.0/go.mod h1:JxZsvoBklA50DNhELVJnJ567Rt+KrMH9rR3u515wvE8=
github.com/go-pkgz/lcw v0.5.0/go.mod h1:CSdQRQthxJQ4iDD4wTPPuWFbFdknJzwJ8WXu1nfxb10=
github.com/go-pkgz/lgr v0.7.0 h1:S/AAPwt/RE9a5mNJskA7dGVp+Dq6SMIW6LYjG3ITxY8=
github.com/go-pkgz/lgr v0.7.0/go.mod h1:yMgxU+GobMRJgIEbSzDKy/67W18S7qmGx/7BVL5AB8Q=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-pkgz/auth v1.18.1-0.20211207184750-78e5d290333d/go.mod h1:N1VM53c22Uw0UQSYgdTOBK1D2vtPjSc/RWGf/Fhc1y8=
github.com/go-pkgz/expirable-cache v0.0.3 h1:rTh6qNPp78z0bQE6HDhXBHUwqnV9i09Vm6dksJLXQDc=
github.com/go-pkgz/expirable-cache v0.0.3/go.mod h1:+IauqN00R2FqNRLCLA+X5YljQJrwB179PfiAoMPlTlQ=
github.com/go-pkgz/jrpc v0.2.0 h1:CLy/eZyekjraVrxZV18N2R1mYLMJ/nWrgdfyIOGPY/E=
github.com/go-pkgz/jrpc v0.2.0/go.mod h1:wd8vtQ4CgtCnuqua6x2b1SKIgv0VSOh5Dn0uUITbiUE=
github.com/go-pkgz/lcw v0.8.1/go.mod h1:Xw0/ZfApATgbjVPYRZO4XHdWyxAjErDWDWJ7TLlw1Vc=
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw=
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0=
github.com/go-pkgz/repeater v1.1.3/go.mod h1:hVTavuO5x3Gxnu8zW7d6sQBfAneKV8X2FjU48kGfpKw=
github.com/go-pkgz/rest v1.4.1 h1:DmaVLPH2O7yLehrWOW0uz01d2mVHz9fBR/iuTiPRzaw=
github.com/go-pkgz/rest v1.4.1/go.mod h1:COazNj35u3RXAgQNBr6neR599tYP3URiOpsu9p0rOtk=
github.com/go-pkgz/rest v1.5.0 h1:C8SxXcXza4GiUUAn/95iCkvoIrGbS30qpwK19iqlrWQ=
github.com/go-pkgz/rest v1.5.0/go.mod h1:nQaM3RhSTUAmbBZWY4hfe4buyeC9VckvhoCktiQXJxI=
github.com/go-pkgz/rest v1.11.0 h1:Z//qgmM0NhBYfhXYEP/aJtDVLK5XlJGxqcb4sHFNN0E=
github.com/go-pkgz/rest v1.11.0/go.mod h1:wZ/dGipZUaF9to0vIQl7PwDHgWQDB0jsrFg1xnAKLDw=
github.com/go-pkgz/syncs v1.1.1/go.mod h1:bt9lxWRRJ9vOCMGc8Big8ttjYHLKP88ofj1y38UlaHE=
github.com/go-redis/redis/v7 v7.0.0-beta.4/go.mod h1:xhhSbUMTsleRPur+Vgx9sUHtyN33bdjxY+9/0n9Ig8s=
github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0=
github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY=
github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg=
github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs=
github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk=
github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28=
github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo=
github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk=
github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw=
github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360=
github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg=
github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE=
github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8=
github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.7.1-0.20190322064113-39e2c31b7ca3/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v0.0.0-20180405133222-e7e905edc00e/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kyokomi/emoji v2.2.1+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc=
github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc=
github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/slack-go/slack v0.9.5/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.0.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
github.com/tidwall/gjson v1.1.3/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA=
github.com/tidwall/buntdb v1.1.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/gjson v1.11.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
github.com/umputun/go-flags v1.5.1 h1:vRauoXV3Ultt1HrxivSxowbintgZLJE+EcBy5ta3/mY=
github.com/umputun/go-flags v1.5.1/go.mod h1:nTbvsO/hKqe7Utri/NoyN18GR3+EWf+9RrmsdwdhrEc=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.0.0/go.mod h1:4vX61m6KN+xDduDNwXrhIAVZaZaZiQ1luJk8LWSxF3s=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v0.0.0-20181112162635-ac52e6811b56/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/gopher-lua v0.0.0-20190514113301-1cd887cd7036/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.mongodb.org/mongo-driver v1.7.4/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20190523035834-f03afa92d3ff/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181217023233-e147a9138326/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211105192438-b53810dc28af h1:SMeNJG/vclJ5wyBBd4xupMsSJIHTd1coW9g7q6KOjmY=
golang.org/x/net v0.0.0-20211105192438-b53810dc28af/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c h1:+8miTPjMCTXwih7BQmvWwd0PjdBZq2MKp/qQaahSzEM=
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.6.0/go.mod h1:btoxGiFvQNVUZQ8W08zLtrVS08CNpINPEfxXxgJL1Q4=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
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-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/oauth2.v3 v3.10.1/go.mod h1:nTG+m2PRcHR9jzGNrGdxSsUKz7vvwkqSlhFrstgZcRU=
gopkg.in/oauth2.v3 v3.12.0/go.mod h1:XEYgKqWX095YiPT+Aw5y3tCn+7/FMnlTFKrupgSiJ3I=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+2 -2
View File
@@ -14,8 +14,8 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/umputun/go-flags"
"github.com/umputun/remark/memory_store/accessor"
"github.com/umputun/remark/memory_store/server"
"github.com/umputun/remark42/memory_store/accessor"
"github.com/umputun/remark42/memory_store/server"
)
// opts with all cli commands and flags
@@ -11,12 +11,17 @@ import (
"github.com/go-pkgz/jrpc"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/admin"
)
// get admin key
func (s *RPC) admKeyHndl(id uint64, _ json.RawMessage) (rr jrpc.Response) {
key, err := s.adm.Key()
func (s *RPC) admKeyHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var siteID string
if err := json.Unmarshal(params, &siteID); err != nil {
return jrpc.Response{Error: err.Error()}
}
key, err := s.adm.Key(siteID)
if err != nil {
return jrpc.Response{Error: err.Error()}
}
@@ -15,7 +15,7 @@ import (
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/admin"
)
func TestRPC_admKeyHndl(t *testing.T) {
@@ -24,7 +24,7 @@ func TestRPC_admKeyHndl(t *testing.T) {
api := fmt.Sprintf("http://localhost:%d/test", port)
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
key, err := ra.Key()
key, err := ra.Key("any")
assert.NoError(t, err)
assert.Equal(t, "secret", key)
}
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/go-pkgz/jrpc"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
func (s *RPC) createHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
@@ -16,8 +16,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
func TestRPC_createHndl(t *testing.T) {
@@ -28,6 +28,16 @@ func (s *RPC) imgSaveWithIDHndl(id uint64, params json.RawMessage) (rr jrpc.Resp
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgResetClnTimerHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.img.ResetCleanupTimer(fileID)
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
@@ -11,7 +11,6 @@ import (
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"testing"
@@ -20,7 +19,7 @@ import (
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/image"
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
@@ -46,7 +45,7 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNGBytes() []byte {
img, _ := ioutil.ReadAll(gopherPNG())
img, _ := io.ReadAll(gopherPNG())
return img
}
@@ -116,7 +115,21 @@ func TestRPC_imgCleanupHndl(t *testing.T) {
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
// cleanup
// wait for image to expire
time.Sleep(time.Millisecond * 50)
// reset the time to cleanup
err = ri.ResetCleanupTimer(id)
assert.NoError(t, err)
// cleanup, should not affect the new image
err = ri.Cleanup(context.TODO(), time.Millisecond*45)
assert.NoError(t, err)
// load after cleanup should succeed
_, err = ri.Load(id)
assert.NoError(t, err, "image is still on staging because it's cleanup timer was reset")
// cleanup with short TTL, should remove the image from staging
err = ri.Cleanup(context.TODO(), time.Nanosecond)
assert.NoError(t, err)
+9 -8
View File
@@ -9,9 +9,9 @@ package server
import (
"github.com/go-pkgz/jrpc"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/image"
)
// RPC handler wraps both engine and remote server and implements all handlers for data store and admin store
@@ -57,10 +57,11 @@ func (s *RPC) addHandlers() {
// image store handlers
s.Group("image", jrpc.HandlersGroup{
"save_with_id": s.imgSaveWithIDHndl,
"load": s.imgLoadHndl,
"commit": s.imgCommitHndl,
"cleanup": s.imgCleanupHndl,
"info": s.imgInfoHndl,
"save_with_id": s.imgSaveWithIDHndl,
"reset_cleanup_timer": s.imgResetClnTimerHndl,
"load": s.imgLoadHndl,
"commit": s.imgCommitHndl,
"cleanup": s.imgCleanupHndl,
"info": s.imgInfoHndl,
})
}
@@ -17,7 +17,7 @@ import (
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/memory_store/accessor"
"github.com/umputun/remark42/memory_store/accessor"
)
func chooseRandomUnusedPort() (port int) {
+2 -2
View File
@@ -72,12 +72,12 @@ func (ac *AvatarCommand) makeAvatarStore(gr AvatarGroup) (avatar.Store, error) {
switch gr.Type {
case "fs":
if err := makeDirs(gr.FS.Path); err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to create avatar store")
}
return avatar.NewLocalFS(gr.FS.Path), nil
case "bolt":
if err := makeDirs(path.Dir(gr.Bolt.File)); err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to create avatar store")
}
return avatar.NewBoltDB(gr.Bolt.File, bolt.Options{})
}
+2 -2
View File
@@ -41,7 +41,7 @@ func (ec *BackupCommand) Execute(_ []string) error {
ctx, cancel := context.WithTimeout(context.Background(), ec.Timeout)
defer cancel()
exportURL := fmt.Sprintf("%s/api/v1/admin/export?mode=file&site=%s", ec.RemarkURL, ec.Site)
req, err := http.NewRequest(http.MethodGet, exportURL, nil)
req, err := http.NewRequest(http.MethodGet, exportURL, http.NoBody)
if err != nil {
return errors.Wrapf(err, "can't make export request for %s", exportURL)
}
@@ -66,7 +66,7 @@ func (ec *BackupCommand) Execute(_ []string) error {
if err != nil {
return errors.Wrapf(err, "can't create backup file %s", fname)
}
defer func() {
defer func() { //nolint:gosec // false positive on defer without error check when it's checked here
if err = fh.Close(); err != nil {
log.Printf("[WARN] failed to close file %s, %s", fh.Name(), err)
}
+1 -2
View File
@@ -2,7 +2,6 @@ package cmd
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
@@ -31,7 +30,7 @@ func TestBackup_Execute(t *testing.T) {
assert.NoError(t, err)
defer os.Remove("/tmp/remark-test.export")
data, err := ioutil.ReadFile("/tmp/remark-test.export")
data, err := os.ReadFile("/tmp/remark-test.export")
require.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(data))
}
+4 -4
View File
@@ -11,7 +11,7 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
// CleanupCommand set of flags and command for cleanup
@@ -193,7 +193,7 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error)
func (cc *CleanupCommand) deleteComment(c store.Comment) error {
deleteURL := fmt.Sprintf("%s/api/v1/admin/comment/%s?site=%s&url=%s&format=plain", cc.RemarkURL, c.ID, cc.Site, c.Locator.URL)
req, err := http.NewRequest("DELETE", deleteURL, nil)
req, err := http.NewRequest("DELETE", deleteURL, http.NoBody)
if err != nil {
return errors.Wrapf(err, "failed to make delete request for comment %s, %s", c.ID, c.Locator.URL)
}
@@ -215,7 +215,7 @@ func (cc *CleanupCommand) deleteComment(c store.Comment) error {
func (cc *CleanupCommand) setTitle(c store.Comment) error {
titleURL := fmt.Sprintf("%s/api/v1/admin/title/%s?site=%s&url=%s&format=plain", cc.RemarkURL, c.ID, cc.Site, c.Locator.URL)
req, err := http.NewRequest("PUT", titleURL, nil)
req, err := http.NewRequest("PUT", titleURL, http.NoBody)
if err != nil {
return errors.Wrapf(err, "failed to make title request for comment %s, %s", c.ID, c.Locator.URL)
}
@@ -234,7 +234,7 @@ func (cc *CleanupCommand) setTitle(c store.Comment) error {
}
// isSpam calculates spam's probability as a score
func (cc *CleanupCommand) isSpam(comment store.Comment) (bool, float64) {
func (cc *CleanupCommand) isSpam(comment store.Comment) (isSpam bool, spamScore float64) {
badWord := func(txt string) float64 {
res := 0.0
+2 -2
View File
@@ -9,12 +9,12 @@ import (
"testing"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/go-flags"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
type cleanedComments struct {
+6 -6
View File
@@ -4,7 +4,7 @@ package cmd
import (
"bytes"
"io/ioutil"
"io"
"net/http"
"os"
"path/filepath"
@@ -33,9 +33,9 @@ type CommonOpts struct {
// DeprecatedFlag contains information about deprecated option
type DeprecatedFlag struct {
Old string
New string
RemoveVersion string
Old string
New string
Version string
}
// SetCommon satisfies CommonOptionsCommander interface and sets common option fields
@@ -103,7 +103,7 @@ func resetEnv(envs ...string) {
// responseError returns error with status and response body
func responseError(resp *http.Response) error {
body, e := ioutil.ReadAll(resp.Body)
body, e := io.ReadAll(resp.Body)
if e != nil {
body = []byte("")
}
@@ -113,7 +113,7 @@ func responseError(resp *http.Response) error {
// mkdir -p for all dirs
func makeDirs(dirs ...string) error {
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0700); err != nil { // If path is already a directory, MkdirAll does nothing
if err := os.MkdirAll(dir, 0o700); err != nil { // If path is already a directory, MkdirAll does nothing
return errors.Wrapf(err, "can't make directory %s", dir)
}
}
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
@@ -18,7 +17,7 @@ import (
// ImportCommand set of flags and command for import
type ImportCommand struct {
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" description:"import format"` //nolint
Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" choice:"commento" description:"import format"` //nolint
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
@@ -58,7 +57,7 @@ func (ic *ImportCommand) Execute(_ []string) error {
return responseError(resp)
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "can't get response from importer")
}
+3 -3
View File
@@ -2,7 +2,7 @@ package cmd
import (
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -20,7 +20,7 @@ func TestImport_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
body, err := ioutil.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
@@ -97,7 +97,7 @@ func TestImport_ExecuteTimeout(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
body, err := ioutil.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
time.Sleep(500 * time.Millisecond)
+2 -2
View File
@@ -3,7 +3,7 @@ package cmd
import (
"context"
"fmt"
"io/ioutil"
"io"
"net/http"
"os"
"time"
@@ -55,7 +55,7 @@ func (rc *RemapCommand) Execute(_ []string) error {
return responseError(resp)
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "can't get response")
}
+2 -2
View File
@@ -1,7 +1,7 @@
package cmd
import (
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -18,7 +18,7 @@ func TestRemap_Execute(t *testing.T) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "remark", r.URL.Query().Get("site"))
body, err := ioutil.ReadAll(r.Body)
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))
+2 -2
View File
@@ -2,7 +2,7 @@ package cmd
import (
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -19,7 +19,7 @@ func TestRestore_Execute(t *testing.T) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "native", r.URL.Query().Get("provider"))
body, err := ioutil.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
+515 -238
View File
File diff suppressed because it is too large Load Diff
+168 -54
View File
@@ -4,7 +4,7 @@ import (
"context"
"crypto/tls"
"fmt"
"io/ioutil"
"io"
"math/rand"
"net"
"net/http"
@@ -15,9 +15,10 @@ import (
"testing"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-pkgz/auth/token"
"github.com/golang-jwt/jwt"
"github.com/umputun/go-flags"
"go.uber.org/goleak"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -38,12 +39,13 @@ func TestServerApp(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
// add comment
client := http.Client{Timeout: 10 * time.Second}
defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
require.NoError(t, err)
@@ -52,7 +54,7 @@ func TestServerApp(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
body, _ = ioutil.ReadAll(resp.Body)
body, _ = io.ReadAll(resp.Body)
t.Log(string(body))
email, err := app.dataService.AdminStore.Email("")
@@ -75,15 +77,16 @@ func TestServerApp_DevMode(t *testing.T) {
go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port)
require.Equal(t, 5+1, len(app.restSrv.Authenticator.Providers()), "extra auth provider")
assert.Equal(t, "dev", app.restSrv.Authenticator.Providers()[4].Name(), "dev auth provider")
providers := app.restSrv.Authenticator.Providers()
require.Equal(t, 9+1, len(providers), "extra auth provider")
assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider")
// send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, "pong", string(body))
cancel()
@@ -101,15 +104,16 @@ func TestServerApp_AnonMode(t *testing.T) {
go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port)
require.Equal(t, 5+1, len(app.restSrv.Authenticator.Providers()), "extra auth provider for anon")
assert.Equal(t, "anonymous", app.restSrv.Authenticator.Providers()[5].Name(), "anon auth provider")
providers := app.restSrv.Authenticator.Providers()
require.Equal(t, 9+1, len(providers), "extra auth provider for anon")
assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider")
// send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
@@ -121,18 +125,26 @@ func TestServerApp_AnonMode(t *testing.T) {
// try to add a comment as good anonymous
client := http.Client{Timeout: 10 * time.Second}
defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
require.NoError(t, err)
tkn, claims := getAuthFromCookie(t, app, resp)
require.NotEmpty(t, tkn)
assert.False(t, claims.User.BoolAttr("blocked"), "should not be blocked")
req.Header.Add("X-JWT", tkn)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
// try to login with non-latin name
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// try to login with bad name
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", port))
require.NoError(t, err)
@@ -140,13 +152,32 @@ func TestServerApp_AnonMode(t *testing.T) {
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with short name
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%20%20&aud=remark", port))
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%%20%%20&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with name what have space in prefix
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%%20somebody&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with name what have space in suffix
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=somebody%%20&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with long name
ln := strings.Repeat("x", 65)
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// try to login with admin name
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umputun&aud=remark", port))
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umpUtun&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -170,16 +201,16 @@ func TestServerApp_AnonMode(t *testing.T) {
app.Wait()
}
func getAuthFromCookie(t *testing.T, app *serverApp, resp *http.Response) (token string, claims token.Claims) {
func getAuthFromCookie(t *testing.T, app *serverApp, resp *http.Response) (tkn string, claims token.Claims) {
var err error
for _, c := range resp.Cookies() {
if c.Name == "JWT" {
token = c.Value
tkn = c.Value
claims, err = app.restSrv.Authenticator.TokenService().Parse(c.Value)
require.NoError(t, err)
}
}
return token, claims
return tkn, claims
}
func TestServerApp_WithSSL(t *testing.T) {
@@ -191,13 +222,13 @@ func TestServerApp_WithSSL(t *testing.T) {
p := flags.NewParser(&opts, flags.Default)
port := chooseRandomUnusedPort()
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=" + strconv.Itoa(port), "--store.bolt.path=/tmp/xyz", "--backup=/tmp",
"--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db", "--notify.type=none",
"--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db",
"--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem",
"--ssl.port=" + strconv.Itoa(sslPort), "--image.fs.path=/tmp"})
require.NoError(t, err)
// create app
app, err := opts.newServerApp()
app, err := opts.newServerApp(context.Background())
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
@@ -228,7 +259,7 @@ func TestServerApp_WithSSL(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
@@ -252,7 +283,7 @@ func TestServerApp_WithRemote(t *testing.T) {
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
// create app
app, err := opts.newServerApp()
app, err := opts.newServerApp(context.Background())
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
@@ -264,7 +295,7 @@ func TestServerApp_WithRemote(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
@@ -281,7 +312,7 @@ func TestServerApp_Failed(t *testing.T) {
// RO bolt location
_, err := p.ParseArgs([]string{"--backup=/tmp", "--store.bolt.path=/dev/null", "--image.fs.path=/tmp"})
assert.NoError(t, err)
_, err = opts.newServerApp()
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err, "failed to make data store engine: failed to create bolt store: can't make directory /dev/null: mkdir /dev/null: not a directory")
t.Log(err)
@@ -291,8 +322,8 @@ func TestServerApp_Failed(t *testing.T) {
_, err = p.ParseArgs([]string{"--store.bolt.path=/tmp", "--backup=/dev/null/not-writable"})
assert.NoError(t, err)
_, err = opts.newServerApp()
assert.EqualError(t, err, "can't make directory /dev/null/not-writable: mkdir /dev/null: not a directory")
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err, "failed to create backup store: can't make directory /dev/null/not-writable: mkdir /dev/null: not a directory")
t.Log(err)
// invalid url
@@ -301,7 +332,7 @@ func TestServerApp_Failed(t *testing.T) {
_, err = p.ParseArgs([]string{"--backup=/tmp", "----store.bolt.path=/tmp"})
assert.NoError(t, err)
_, err = opts.newServerApp()
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
t.Log(err)
@@ -312,9 +343,22 @@ func TestServerApp_Failed(t *testing.T) {
assert.Error(t, err, "blah is invalid type")
opts.Store.Type = "blah"
_, err = opts.newServerApp()
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err, "failed to make data store engine: unsupported store type blah")
t.Log(err)
// wrong redis location
opts = ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
p = flags.NewParser(&opts, flags.Default)
_, err = p.ParseArgs([]string{"--store.bolt.path=/tmp", "--cache.type=redis_pub_sub", "--cache.redis_addr=wrong_address"})
assert.NoError(t, err)
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err,
"failed to make cache: cache backend initialization, redis PubSub initialisation: "+
"problem subscribing to channel remark42-cache on address wrong_address: "+
"dial tcp: address wrong_address: missing port in address")
t.Log(err)
}
func TestServerApp_Shutdown(t *testing.T) {
@@ -348,7 +392,7 @@ func TestServerApp_MainSignal(t *testing.T) {
p := flags.NewParser(&s, flags.Default)
port := chooseRandomUnusedPort()
args := []string{"test", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt",
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--notify.type=none", "--image.fs.path=/tmp"}
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--image.fs.path=/tmp"}
defer os.Remove("/tmp/ava-test.db")
_, err := p.ParseArgs(args)
require.NoError(t, err)
@@ -366,12 +410,16 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
p := flags.NewParser(&s, flags.Default)
args := []string{
"test",
"--notify.type=telegram",
"--auth.email.host=smtp.example.org",
"--auth.email.port=666",
"--auth.email.tls",
"--auth.email.user=test_user",
"--auth.email.passwd=test_password",
"--auth.email.timeout=15s",
"--auth.email.template=file.tmpl",
"--notify.telegram.token=abcd",
"--notify.telegram.timeout=3m",
}
assert.Empty(t, s.SMTP.Host)
assert.Empty(t, s.SMTP.Port)
@@ -384,12 +432,16 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
deprecatedFlags := s.HandleDeprecatedFlags()
assert.ElementsMatch(t,
[]DeprecatedFlag{
{Old: "auth.email.host", New: "smtp.host", RemoveVersion: "1.7.0"},
{Old: "auth.email.port", New: "smtp.port", RemoveVersion: "1.7.0"},
{Old: "auth.email.tls", New: "smtp.tls", RemoveVersion: "1.7.0"},
{Old: "auth.email.user", New: "smtp.username", RemoveVersion: "1.7.0"},
{Old: "auth.email.passwd", New: "smtp.password", RemoveVersion: "1.7.0"},
{Old: "auth.email.timeout", New: "smtp.timeout", RemoveVersion: "1.7.0"},
{Old: "auth.email.host", New: "smtp.host", Version: "1.5"},
{Old: "auth.email.port", New: "smtp.port", Version: "1.5"},
{Old: "auth.email.tls", New: "smtp.tls", Version: "1.5"},
{Old: "auth.email.user", New: "smtp.username", Version: "1.5"},
{Old: "auth.email.passwd", New: "smtp.password", Version: "1.5"},
{Old: "auth.email.timeout", New: "smtp.timeout", Version: "1.5"},
{Old: "auth.email.template", Version: "1.5"},
{Old: "notify.type", New: "notify.(users|admins)", Version: "1.9"},
{Old: "notify.telegram.token", New: "telegram.token", Version: "1.9"},
{Old: "notify.telegram.timeout", New: "telegram.timeout", Version: "1.9"},
},
deprecatedFlags)
assert.Equal(t, "smtp.example.org", s.SMTP.Host)
@@ -480,7 +532,7 @@ func TestServerAuthHooks(t *testing.T) {
req.Header.Set("X-JWT", tk)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusCreated, resp.StatusCode, "non-blocked user able to post")
// add comment with no-aud claim
@@ -490,28 +542,28 @@ func TestServerAuthHooks(t *testing.T) {
require.NoError(t, err)
t.Logf("no-aud claims: %s", tkNoAud)
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-631/",
"site": "remark"}}`))
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-631/",
"site": "remark"}}`))
require.NoError(t, err)
req.Header.Set("X-JWT", tkNoAud)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without aud claim rejected, \n"+tkNoAud+"\n"+string(body))
// block user dev as admin
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/dev?site=remark&block=1&ttl=10d", port), nil)
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/dev?site=remark&block=1&ttl=10d", port), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "user dev blocked")
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
t.Log(string(b))
// try add a comment with blocked user
@@ -521,29 +573,74 @@ func TestServerAuthHooks(t *testing.T) {
req.Header.Set("X-JWT", tk)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
"blocked user can't post, \n"+tk+"\n"+string(body))
cancel()
app.Wait()
client.CloseIdleConnections()
}
func TestServer_loadEmailTemplate(t *testing.T) {
cmd := ServerCommand{}
cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl"
r := cmd.loadEmailTemplate()
r, err := cmd.loadEmailTemplate()
assert.NoError(t, err)
assert.Equal(t, "The token is {{.Token}}", r)
cmd.Auth.Email.MsgTemplate = ""
r = cmd.loadEmailTemplate()
assert.Contains(t, r, "Remark42</h1>")
cmd.Auth.Email.MsgTemplate = "badpath.tmpl"
r, err = cmd.loadEmailTemplate()
assert.EqualError(t, err, "failed to read file badpath.tmpl: open badpath.tmpl: no such file or directory")
assert.Equal(t, r, "")
}
cmd.Auth.Email.MsgTemplate = "bad-file"
r = cmd.loadEmailTemplate()
assert.Contains(t, r, "Remark42</h1>")
func TestServerCommand_parseSameSite(t *testing.T) {
tbl := []struct {
inp string
res http.SameSite
}{
{"", http.SameSiteDefaultMode},
{"default", http.SameSiteDefaultMode},
{"blah", http.SameSiteDefaultMode},
{"none", http.SameSiteNoneMode},
{"lax", http.SameSiteLaxMode},
{"strict", http.SameSiteStrictMode},
}
cmd := ServerCommand{}
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
assert.Equal(t, tt.res, cmd.parseSameSite(tt.inp))
})
}
}
func Test_splitAtCommas(t *testing.T) {
tbl := []struct {
inp string
res []string
}{
{"a string", []string{"a string"}},
{"vv1, vv2, vv3", []string{"vv1", "vv2", "vv3"}},
{`"vv1, blah", vv2, vv3`, []string{"vv1, blah", "vv2", "vv3"}},
{
`Access-Control-Allow-Headers:"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type",header123:val, foo:"bar1,bar2"`,
[]string{"Access-Control-Allow-Headers:\"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type\"", "header123:val", "foo:\"bar1,bar2\""},
},
{"", []string{}},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
assert.Equal(t, tt.res, splitAtCommas(tt.inp))
})
}
}
func chooseRandomUnusedPort() (port int) {
@@ -596,10 +693,16 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
cmd.Auth.Google.CSEC, cmd.Auth.Google.CID = "csec", "cid"
cmd.Auth.Facebook.CSEC, cmd.Auth.Facebook.CID = "csec", "cid"
cmd.Auth.Yandex.CSEC, cmd.Auth.Yandex.CID = "csec", "cid"
cmd.Auth.Microsoft.CSEC, cmd.Auth.Microsoft.CID = "csec", "cid"
cmd.Auth.Twitter.CSEC, cmd.Auth.Twitter.CID = "csec", "cid"
cmd.Auth.Patreon.CSEC, cmd.Auth.Patreon.CID = "csec", "cid"
cmd.Auth.Telegram = true
cmd.Telegram.Token = "token"
cmd.Auth.Email.Enable = true
cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl"
cmd.BackupLocation = "/tmp"
cmd.Notify.Type = []string{"email"}
cmd.Notify.Users = []string{"email"}
cmd.Notify.Admins = []string{"email"}
cmd.Notify.Email.From = "from@example.org"
cmd.Notify.Email.VerificationSubject = "test verification email subject"
cmd.SMTP.Host = "127.0.0.1"
@@ -609,16 +712,27 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
cmd.SMTP.TimeOut = time.Second
cmd.UpdateLimit = 10
cmd.Admin.Type = "shared"
cmd.Admin.Shared.Admins = []string{"umputun", "bobuk"}
cmd.Admin.Shared.Admins = []string{"id1", "id2"}
cmd.RestrictedNames = []string{"umputun", "bobuk"}
cmd.emailMsgTemplatePath = "../../templates/email_reply.html.tmpl"
cmd.emailVerificationTemplatePath = "../../templates/email_confirmation_subscription.html.tmpl"
cmd = fn(cmd)
os.Remove(cmd.Store.Bolt.Path + "/remark.db")
// create app
app, err := cmd.newServerApp()
return createAppFromCmd(t, cmd)
}
func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
app, err := cmd.newServerApp(context.Background())
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
rand.Seed(time.Now().UnixNano())
return app, ctx, cancel
}
func TestMain(m *testing.M) {
// ignore is added only for GitHub Actions, can't reproduce locally
goleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"))
}
+9 -6
View File
@@ -10,7 +10,7 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/umputun/go-flags"
"github.com/umputun/remark/backend/app/cmd"
"github.com/umputun/remark42/backend/app/cmd"
)
// Opts with all cli commands and flags
@@ -24,7 +24,7 @@ type Opts struct {
RemapCmd cmd.RemapCommand `command:"remap"`
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"`
SharedSecret string `long:"secret" env:"SECRET" required:"true" description:"shared secret key"`
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"`
}
@@ -46,8 +46,11 @@ func main() {
Revision: revision,
})
for _, entry := range c.HandleDeprecatedFlags() {
log.Printf("[WARN] --%s is deprecated and will be removed in v%s, please use --%s instead",
entry.Old, entry.RemoveVersion, entry.New)
deprecationNote := fmt.Sprintf("[WARN] --%s is deprecated since v%s and will be removed in the future", entry.Old, entry.Version)
if entry.New != "" {
deprecationNote += fmt.Sprintf(", please use --%s instead", entry.New)
}
log.Print(deprecationNote)
}
err := c.Execute(args)
if err != nil {
@@ -84,10 +87,10 @@ func getDump() string {
return string(stacktrace[:length])
}
// nolint:gochecknoinits
// nolint:gochecknoinits // can't avoid it in this place
func init() {
// catch SIGQUIT and print stack traces
sigChan := make(chan os.Signal)
sigChan := make(chan os.Signal, 1)
go func() {
for range sigChan {
log.Printf("[INFO] SIGQUIT detected, dump:\n%s", getDump())
+75 -1
View File
@@ -2,19 +2,23 @@ package main
import (
"fmt"
"io"
"io/ioutil"
"math/rand"
"net"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
func Test_Main(t *testing.T) {
@@ -51,11 +55,72 @@ func Test_Main(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
}
func TestMain_WithWebhook(t *testing.T) {
dir, err := ioutil.TempDir(os.TempDir(), "remark42")
require.NoError(t, err)
defer os.RemoveAll(dir)
var webhookSent int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.StoreInt32(&webhookSent, 1)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
b, e := io.ReadAll(r.Body)
defer r.Body.Close()
assert.Nil(t, e)
assert.Equal(t, "Comment: env test", string(b))
}))
defer ts.Close()
port := chooseRandomUnusedPort()
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg",
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
err = os.Setenv("NOTIFY_WEBHOOK_URL", ts.URL)
assert.NoError(t, err)
err = os.Setenv("NOTIFY_WEBHOOK_TEMPLATE", "Comment: {{.Orig}}")
assert.NoError(t, err)
err = os.Setenv("NOTIFY_WEBHOOK_HEADERS", "Content-Type:application/json")
assert.NoError(t, err)
done := make(chan struct{})
go func() {
<-done
e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
require.NoError(t, e)
}()
finished := make(chan struct{})
go func() {
main()
assert.Eventually(t, func() bool {
return atomic.LoadInt32(&webhookSent) == int32(1)
}, time.Second, 100*time.Millisecond, "webhook was not sent")
close(finished)
}()
// defer cleanup because require check below can fail
defer func() {
close(done)
<-finished
}()
waitForHTTPServerStart(port)
resp, err := http.Post(fmt.Sprintf("http://admin:password@localhost:%d/api/v1/comment", port), "",
strings.NewReader(`{"text": "env test", "locator":{"url": "https://radio-t.com", "site": "remark"}}`))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 201, resp.StatusCode)
}
func TestGetDump(t *testing.T) {
dump := getDump()
assert.True(t, strings.Contains(dump, "goroutine"))
@@ -86,3 +151,12 @@ func waitForHTTPServerStart(port int) {
}
}
}
func TestMain(m *testing.M) {
// both ignores are for leaks which are detected locally
goleak.VerifyTestMain(
m,
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
)
}
+8 -3
View File
@@ -4,7 +4,6 @@ import (
"compress/gzip"
"context"
"fmt"
"io/ioutil"
"os"
"sort"
"strings"
@@ -27,6 +26,7 @@ type AutoBackup struct {
func (ab AutoBackup) Do(ctx context.Context) {
log.Printf("[INFO] activate auto-backup for %s under %s, duration %s", ab.SiteID, ab.BackupLocation, ab.Duration)
tick := time.NewTicker(ab.Duration)
defer tick.Stop()
log.Printf("[DEBUG] first backup for %s at %s", ab.SiteID, time.Now().Add(ab.Duration))
for {
@@ -68,15 +68,20 @@ func (ab AutoBackup) makeBackup() (string, error) {
}
func (ab AutoBackup) removeOldBackupFiles() {
files, err := ioutil.ReadDir(ab.BackupLocation)
files, err := os.ReadDir(ab.BackupLocation)
if err != nil {
log.Printf("[WARN] can't read files in backup directory %s, %s", ab.BackupLocation, err)
return
}
backFiles := []os.FileInfo{}
for _, file := range files {
info, e := file.Info()
if e != nil {
log.Printf("[WARN] can't read info for directory %s, %s", file.Name(), e)
return
}
if strings.HasPrefix(file.Name(), "backup-"+ab.SiteID) {
backFiles = append(backFiles, file)
backFiles = append(backFiles, info)
}
}
sort.Slice(backFiles, func(i int, j int) bool { return backFiles[i].Name() < backFiles[j].Name() })
+6 -7
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io"
"io/ioutil"
"os"
"testing"
"time"
@@ -17,20 +16,20 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) {
loc := "/tmp/remark-backups.test"
defer os.RemoveAll(loc)
assert.NoError(t, os.MkdirAll(loc, 0700))
assert.NoError(t, os.MkdirAll(loc, 0o700))
for i := 1; i <= 10; i++ {
fname := fmt.Sprintf("%s/backup-site1-201712%02d.gz", loc, i)
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
err := os.WriteFile(fname, []byte("blah"), 0o600)
assert.NoError(t, err)
}
fname := fmt.Sprintf("%s/backup-site2-20171210.gz", loc)
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
err := os.WriteFile(fname, []byte("blah"), 0o600)
assert.NoError(t, err)
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3}
bk.removeOldBackupFiles()
ff, err := ioutil.ReadDir(loc)
ff, err := os.ReadDir(loc)
assert.NoError(t, err)
require.Equal(t, 4, len(ff), "should keep 4 files - 3 kept for sit1, and one for site2")
assert.Equal(t, "backup-site1-20171208.gz", ff[0].Name())
@@ -42,7 +41,7 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) {
func TestBackup_MakeBackup(t *testing.T) {
loc := "/tmp/remark-backups.test"
defer os.RemoveAll(loc)
assert.NoError(t, os.MkdirAll(loc, 0700))
assert.NoError(t, os.MkdirAll(loc, 0o700))
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}}
fname, err := bk.makeBackup()
@@ -58,7 +57,7 @@ func TestBackup_MakeBackup(t *testing.T) {
func TestBackup_Do(t *testing.T) {
loc := "/tmp/remark-backups.test"
defer os.RemoveAll(loc)
assert.NoError(t, os.MkdirAll(loc, 0700))
assert.NoError(t, os.MkdirAll(loc, 0o700))
ctx, cancel := context.WithCancel(context.Background())
go func() {
+134
View File
@@ -0,0 +1,134 @@
package migrator
import (
"encoding/json"
"io"
"time"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store"
log "github.com/go-pkgz/lgr"
)
// Commento implements Importer from commento export json
type Commento struct {
DataStore Store
}
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/domain_import_commento.go#L11-L15
type commentoExport struct {
Version int `json:"version"`
Comments []commentoComment `json:"comments"`
Commenters []commentoCommenter `json:"commenters"`
}
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/comment.go#L7-L20
type commentoComment struct {
CommentHex string `json:"commentHex"`
Domain string `json:"domain,omitempty"`
Path string `json:"url,omitempty"`
CommenterHex string `json:"commenterHex"`
Markdown string `json:"markdown"`
HTML string `json:"html"`
ParentHex string `json:"parentHex"`
Score int `json:"score"`
State string `json:"state,omitempty"`
CreationDate time.Time `json:"creationDate"`
Direction int `json:"direction"`
Deleted bool `json:"deleted"`
}
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/commenter.go#L7-L16
type commentoCommenter struct {
CommenterHex string `json:"commenterHex,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name"`
Link string `json:"link"`
Photo string `json:"photo"`
Provider string `json:"provider,omitempty"`
JoinDate time.Time `json:"joinDate,omitempty"`
IsModerator bool `json:"isModerator"`
}
// Import comments from Commento and save to store
func (d *Commento) Import(r io.Reader, siteID string) (size int, err error) {
if e := d.DataStore.DeleteAll(siteID); e != nil {
return 0, e
}
commentsCh := d.convert(r, siteID)
failed, passed := 0, 0
for c := range commentsCh {
if _, err = d.DataStore.Create(c); err != nil {
failed++
continue
}
passed++
}
if failed > 0 {
err = errors.Errorf("failed to save %d comments", failed)
if passed == 0 {
err = errors.New("import failed")
}
}
log.Printf("[DEBUG] imported %d comments to site %s", passed, siteID)
return passed, err
}
func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
commentsCh := make(chan store.Comment)
decoder := json.NewDecoder(r)
go func() {
var exportedData commentoExport
err := decoder.Decode(&exportedData)
if err != nil {
log.Printf("[WARN] can't decode commento export json, %s", err.Error())
}
usersMap := map[string]store.User{}
for _, commenter := range exportedData.Commenters {
usersMap[commenter.CommenterHex] = store.User{
Name: commenter.Name,
ID: "commento_" + store.EncodeID(commenter.CommenterHex),
Picture: commenter.Photo,
}
}
for _, comment := range exportedData.Comments {
u, ok := usersMap[comment.CommenterHex]
if !ok {
continue
}
if comment.Deleted {
continue
}
c := store.Comment{
ID: comment.CommentHex,
Locator: store.Locator{
URL: comment.Path,
SiteID: siteID,
},
User: u,
Text: comment.Markdown,
Timestamp: comment.CreationDate,
ParentID: comment.ParentHex,
Imported: true,
}
commentsCh <- c
}
close(commentsCh)
}()
return commentsCh
}
+53
View File
@@ -0,0 +1,53 @@
package migrator
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/service"
bolt "go.etcd.io/bbolt"
)
func TestCommento_Import(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Commento{DataStore: &dataStore}
fh, err := os.Open("testdata/commento.json")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 2, size)
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 2, len(last), "2 comments imported")
t.Log(last[0])
c := last[0] // last reverses, get first one
assert.Equal(t, "Great reply!", c.Text)
assert.Equal(t, "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9", c.ID)
assert.Equal(t, "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854", c.ParentID)
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"}, c.Locator)
assert.Equal(t, "Saturnin Uf", c.User.Name)
assert.Equal(t, "commento_35369aeb6ac5255de30410a0f86dc71eb9c6d0ca", c.User.ID)
assert.True(t, c.Imported)
posts, err := dataStore.List("test", 0, 0)
assert.NoError(t, err)
assert.Equal(t, 1, len(posts), "1 post")
count, err := dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"})
assert.NoError(t, err)
assert.Equal(t, 2, count)
}
+29 -12
View File
@@ -9,7 +9,7 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
// Disqus implements Importer from disqus xml
@@ -44,6 +44,7 @@ type disqusComment struct {
Tid uid `xml:"thread"`
Pid uid `xml:"parent"`
IsSpam bool `xml:"isSpam"`
Deleted bool `xml:"isDeleted"`
}
type uid struct {
@@ -52,9 +53,8 @@ type uid struct {
// Import from disqus and save to store
func (d *Disqus) Import(r io.Reader, siteID string) (size int, err error) {
if err = d.DataStore.DeleteAll(siteID); err != nil {
return 0, err
if e := d.DataStore.DeleteAll(siteID); e != nil {
return 0, e
}
commentsCh := d.convert(r, siteID)
@@ -88,9 +88,10 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
commentsCh := make(chan store.Comment)
stats := struct {
inpThreads, inpComments int
commentsCount, spamComments int
failedThreads, failedPosts int
inpThreads, inpComments int
commentsCount, spamComments int
failedThreads, failedPosts int
deletedComments, skippedComments int
}{}
go func() {
@@ -100,8 +101,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
break
}
switch se := t.(type) {
case xml.StartElement:
if se, ok := t.(xml.StartElement); ok {
if se.Name.Local == "thread" {
stats.inpThreads++
thread := disqusThread{}
@@ -110,9 +110,13 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
stats.failedThreads++
continue
}
if thread.Deleted {
continue
}
postsMap[thread.UID] = thread.Link
continue
}
if se.Name.Local == "post" {
stats.inpComments++
comment := disqusComment{}
@@ -121,13 +125,24 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
stats.failedPosts++
continue
}
if comment.Deleted {
stats.deletedComments++
continue
}
if comment.IsSpam {
stats.spamComments++
continue
}
url, ok := postsMap[comment.Tid.Val]
if !ok {
stats.skippedComments++
continue
}
c := store.Comment{
ID: comment.UID,
Locator: store.Locator{URL: postsMap[comment.Tid.Val], SiteID: siteID},
Locator: store.Locator{URL: url, SiteID: siteID},
User: store.User{
ID: "disqus_" + store.EncodeID(comment.AuthorUserName),
Name: comment.AuthorName,
@@ -136,9 +151,10 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
Text: d.cleanText(comment.Message),
Timestamp: comment.CreatedAt,
ParentID: comment.Pid.Val,
Imported: true,
}
if c.User.ID == "disqus_" { // empty comment.AuthorUserName from disqus
c.User.ID = "disqus_" + c.User.Name
if comment.AuthorUserName == "" { // empty comment.AuthorUserName from disqus
c.User.ID = "disqus_" + store.EncodeID(c.User.Name)
}
if c.ID == "" { // no comment.UID
c.ID = comment.ID
@@ -159,6 +175,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
}
func (*Disqus) cleanText(text string) string {
text = strings.TrimSpace(text)
text = strings.Replace(text, "\n", "", -1)
text = strings.Replace(text, "\t", "", -1)
return text
+76 -159
View File
@@ -10,10 +10,10 @@ import (
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestDisqus_Import(t *testing.T) {
@@ -23,7 +23,9 @@ func TestDisqus_Import(t *testing.T) {
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Disqus{DataStore: &dataStore}
size, err := d.Import(strings.NewReader(xmlTestDisqus), "test")
fh, err := os.Open("testdata/disqus.xml")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 4, size)
@@ -39,6 +41,11 @@ func TestDisqus_Import(t *testing.T) {
assert.Equal(t, "Alexander Blah", c.User.Name)
assert.Equal(t, "disqus_328c8b68974aef73785f6b38c3d3fedfdf941434", c.User.ID)
assert.Equal(t, "2ba6b71dbf9750ae3356cce14cac6c1b1962747c", c.User.IP)
assert.True(t, c.Imported)
c = last[1] // get comment with empty username
assert.Equal(t, "No Username", c.User.Name)
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
posts, err := dataStore.List("test", 0, 0)
assert.NoError(t, err)
@@ -49,9 +56,71 @@ func TestDisqus_Import(t *testing.T) {
assert.Equal(t, 2, count)
}
func TestDisqus_ImportDeletedThread(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Disqus{DataStore: &dataStore}
fh, err := os.Open("testdata/disqus-deleted-thread.xml")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 2, size)
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 2, len(last), "2 comments imported")
c := last[len(last)-1] // last reverses, get first one
assert.True(t, strings.HasPrefix(c.Text, "<p>Google App Engine "), c.Text)
assert.Equal(t, "299986072", c.ID)
assert.Equal(t, "", c.ParentID)
assert.Equal(t, store.Locator{SiteID: "test", URL: "http://radio-t.umputun.com/2011/03/229_8880.html"}, c.Locator)
assert.Equal(t, "No Username", c.User.Name)
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
assert.Equal(t, "7001968ea3f6c9013a9f0a3650f200c10c927638", c.User.IP)
assert.True(t, c.Imported)
c = last[1] // get comment with empty username
assert.Equal(t, "No Username", c.User.Name)
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
}
func TestDisqus_ImportDeletedPost(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
d := Disqus{DataStore: &dataStore}
fh, err := os.Open("testdata/disqus-deleted-post.xml")
require.NoError(t, err)
size, err := d.Import(fh, "test")
assert.NoError(t, err)
assert.Equal(t, 3, size, "1 post deleted")
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 3, len(last), "3 comments imported")
c := last[len(last)-1] // last reverses, get first one
assert.True(t, strings.HasPrefix(c.Text, "<p>Microsoft "), c.Text)
assert.Equal(t, "299744309", c.ID)
assert.Equal(t, "", c.ParentID)
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://radio-t.com/p/2011/03/05/podcast-229/"}, c.Locator)
assert.Equal(t, "mikhail", c.User.Name)
assert.Equal(t, "disqus_1b6709749c0cab163db9070cc4edf3322b398d8c", c.User.ID)
assert.Equal(t, "9d3657a95a4e341510404bd8bf1a363faefd4ba4", c.User.IP)
assert.True(t, c.Imported)
}
func TestDisqus_Convert(t *testing.T) {
d := Disqus{}
ch := d.convert(strings.NewReader(xmlTestDisqus), "test")
fh, err := os.Open("testdata/disqus.xml")
require.NoError(t, err)
ch := d.convert(fh, "test")
res := []store.Comment{}
for comment := range ch {
@@ -71,160 +140,8 @@ func TestDisqus_Convert(t *testing.T) {
ID: "disqus_328c8b68974aef73785f6b38c3d3fedfdf941434",
IP: "178.178.178.178",
},
Imported: true,
}
exp0.Timestamp, _ = time.Parse("2006-01-02T15:04:05Z", "2011-08-31T15:16:29Z")
assert.Equal(t, exp0, res[0])
}
var xmlTestDisqus = `<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>false</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>dmitri.noname@gmail.com</email>
<name>Dmitry Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
`
+1 -2
View File
@@ -3,7 +3,6 @@ package migrator
import (
"errors"
"io"
"io/ioutil"
"strings"
)
@@ -30,7 +29,7 @@ func NewURLMapper(reader io.Reader) (Mapper, error) {
// https://www.myblog.com/blog/1/ https://myblog.com/blog/1/
// https://www.myblog.com/* https://myblog.com/*
func (u *URLMapper) loadRules(reader io.Reader) error {
data, err := ioutil.ReadAll(reader)
data, err := io.ReadAll(reader)
if err != nil {
return err
}
+5 -3
View File
@@ -10,8 +10,8 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
)
// Importer defines interface to convert posts from external sources
@@ -64,6 +64,8 @@ func ImportComments(p ImportParams) (int, error) {
importer = &Disqus{DataStore: p.DataStore}
case "wordpress":
importer = &WordPress{DataStore: p.DataStore}
case "commento":
importer = &Commento{DataStore: p.DataStore}
case "native":
importer = &Native{DataStore: p.DataStore}
default:
@@ -75,7 +77,7 @@ func ImportComments(p ImportParams) (int, error) {
return 0, errors.Wrapf(err, "can't open import file %s", p.InputFile)
}
defer func() {
defer func() { //nolint:gosec // false positive on defer without error check when it's checked here
if err = fh.Close(); err != nil {
log.Printf("[WARN] can't close %s, %s", p.InputFile, err)
}
+29 -15
View File
@@ -1,7 +1,6 @@
package migrator
import (
"io/ioutil"
"os"
"testing"
"time"
@@ -10,20 +9,14 @@ import (
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestMigrator_ImportDisqus(t *testing.T) {
defer func() {
os.Remove("/tmp/remark-test.db")
os.Remove("/tmp/disqus-test.xml")
}()
err := ioutil.WriteFile("/tmp/disqus-test.xml", []byte(xmlTestDisqus), 0600)
require.NoError(t, err)
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
@@ -31,7 +24,7 @@ func TestMigrator_ImportDisqus(t *testing.T) {
defer dataStore.Close()
size, err := ImportComments(ImportParams{
DataStore: dataStore,
InputFile: "/tmp/disqus-test.xml",
InputFile: "testdata/disqus.xml",
SiteID: "test",
Provider: "disqus",
})
@@ -49,7 +42,7 @@ func TestMigrator_ImportWordPress(t *testing.T) {
os.Remove("/tmp/wordpress-test.xml")
}()
err := ioutil.WriteFile("/tmp/wordpress-test.xml", []byte(xmlTestWP), 0600)
err := os.WriteFile("/tmp/wordpress-test.xml", []byte(xmlTestWP), 0o600)
require.NoError(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
@@ -70,6 +63,27 @@ func TestMigrator_ImportWordPress(t *testing.T) {
assert.Equal(t, 3, len(last), "3 comments imported")
}
func TestMigrator_ImportCommento(t *testing.T) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.NoError(t, err, "create store")
dataStore := &service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
size, err := ImportComments(ImportParams{
DataStore: dataStore,
InputFile: "testdata/commento.json",
SiteID: "test",
Provider: "commento",
})
assert.NoError(t, err)
assert.Equal(t, 2, size)
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
assert.NoError(t, err)
assert.Equal(t, 2, len(last), "2 comments imported")
}
func TestMigrator_ImportNative(t *testing.T) {
defer func() {
os.Remove("/tmp/remark-test.db")
@@ -79,7 +93,7 @@ func TestMigrator_ImportNative(t *testing.T) {
data := `{"version":1} {"id":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"","text":"some text, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","profile":"","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:22-06:00"}` + "\n" +
`{"id":"afbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","text":"some text2, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","profile":"","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00"}` + "\n"
err := ioutil.WriteFile("/tmp/disqus-test.r42", []byte(data), 0600)
err := os.WriteFile("/tmp/disqus-test.r42", []byte(data), 0o600)
require.NoError(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "radio-t"})
+5 -4
View File
@@ -11,8 +11,8 @@ import (
"github.com/go-pkgz/syncs"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
)
const nativeVersion = 1
@@ -140,8 +140,8 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
return 0, errors.Errorf("unexpected import file version %d", m.Version)
}
if err = n.DataStore.DeleteAll(siteID); err != nil {
return 0, err
if e := n.DataStore.DeleteAll(siteID); e != nil {
return 0, e
}
var failed, total, comments int64
@@ -155,6 +155,7 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
for {
comment := store.Comment{}
err = dec.Decode(&comment)
comment.Imported = true
if err == io.EOF {
break
}
+11 -9
View File
@@ -14,10 +14,10 @@ import (
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestNative_Export(t *testing.T) {
@@ -73,7 +73,7 @@ func TestNative_Import(t *testing.T) {
inp := `{"version":1,"users":[{"id":"user1","blocked":{"status":false,"until":"0001-01-01T00:00:00Z"},"verified":true},{"id":"user2","blocked":{"status":true,"until":"2018-12-23T02:55:22.472041-06:00"},"verified":false}],"posts":[{"url":"https://radio-t.com","read_only":true}]}
{"id":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"","text":"some text, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:22-06:00"}
{"id":"f863bd79-fec6-4a75-b308-61fe5dd02aa1","pid":"1234","text":"some text2","user":{"name":"user name","id":"user2","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com/2"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00"}`
{"id":"f863bd79-fec6-4a75-b308-61fe5dd02aa1","pid":"1234","text":"some text2","user":{"name":"user name","id":"user2","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com/2"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00","imported":false}`
b.AdminStore = admin.NewStaticStore("12345", nil, []string{}, "")
r := Native{DataStore: b}
@@ -87,10 +87,12 @@ func TestNative_Import(t *testing.T) {
assert.Equal(t, "f863bd79-fec6-4a75-b308-61fe5dd02aa1", comments[0].ID)
assert.Equal(t, "1234", comments[0].ParentID)
assert.Equal(t, false, b.IsReadOnly(comments[0].Locator))
assert.True(t, comments[0].Imported)
assert.Equal(t, "efbc17f177ee1a1c0ee6e1e025749966ec071adc", comments[1].ID)
assert.Equal(t, "https://radio-t.com", comments[1].Locator.URL)
assert.Equal(t, true, b.IsReadOnly(comments[1].Locator))
assert.True(t, comments[1].Imported)
assert.Equal(t, false, b.IsBlocked("radio-t", "user1"))
assert.Equal(t, true, b.IsVerified("radio-t", "user1"))
@@ -178,11 +180,11 @@ func TestNative_ImportManyWithError(t *testing.T) {
}
// makes new boltdb, put two records
func prep(t *testing.T) (*service.DataStore, func()) {
func prep(t *testing.T) (ds *service.DataStore, teardown func()) {
testDb := fmt.Sprintf("/tmp/migrator-%d.db", rand.Intn(999999999))
testDB := fmt.Sprintf("/tmp/migrator-%d.db", rand.Intn(999999999))
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{SiteID: "radio-t", FileName: testDb})
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{SiteID: "radio-t", FileName: testDB})
assert.NoError(t, err)
b := &service.DataStore{Engine: boltStore, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
@@ -207,6 +209,6 @@ func prep(t *testing.T) (*service.DataStore, func()) {
return b, func() {
require.NoError(t, b.Close())
_ = os.Remove(testDb)
_ = os.Remove(testDB)
}
}
+69
View File
@@ -0,0 +1,69 @@
{
"version": 1,
"comments": [
{
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
"domain": "example.com",
"url": "https://example.com/blog/post/1",
"commenterHex": "anonymous",
"markdown": "Example comment created by user.",
"html": "",
"parentHex": "root",
"score": 1,
"state": "approved",
"creationDate": "2021-03-12T11:21:56Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"domain": "example.com",
"url": "https://example.com/blog/post/1",
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"markdown": "Example 2 comment created by user.",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2021-03-17T12:09:47.722181Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
"domain": "example.com",
"url": "https://example.com/blog/post/1",
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
"markdown": "Great reply!",
"html": "",
"parentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"score": 0,
"state": "approved",
"creationDate": "2021-05-11T15:43:01.852651Z",
"direction": 0,
"deleted": false
}
],
"commenters": [
{
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"email": "somegreatmail@gmail.com",
"name": "User5276",
"link": "https://example.com/profile/257",
"photo": "https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
"provider": "sso:example.com",
"joinDate": "2021-03-19T19:27:25.954285Z",
"isModerator": false
},
{
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
"email": "moregreatmail@gmail.com",
"name": "Saturnin Uf",
"link": "https://example.com/profile/259",
"photo": "https://secure.gravatar.com/avatar/6481228d190f0286a42bee9041f9b1a1",
"provider": "sso:example.com",
"joinDate": "2021-03-21T12:15:37.536035Z",
"isModerator": false
}
]
}
+150
View File
@@ -0,0 +1,150 @@
`<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>false</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>true</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
+150
View File
@@ -0,0 +1,150 @@
`<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>true</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
+150
View File
@@ -0,0 +1,150 @@
`<?xml version="1.0" encoding="utf-8"?>
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
<category dsq:id="707279">
<forum>radiot</forum>
<title>General</title>
<isDefault>true</isDefault>
</category>
<thread dsq:id="247918464">
<id/>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T20:46:25Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>98.212.28.115</ipAddress>
<isClosed>false</isClosed>
<isDeleted>false</isDeleted>
</thread>
<thread dsq:id="247937687">
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
<forum>radiot</forum>
<category dsq:id="707279"/>
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
<title>Радио-Т: Радио-Т 229</title>
<message/>
<createdAt>2011-03-07T21:17:17Z</createdAt>
<author>
<email>umputun@gmail.com</email>
<name>Umputun</name>
<isAnonymous>false</isAnonymous>
<username>umputun</username>
</author>
<ipAddress>80.250.214.235</ipAddress>
<isClosed>true</isClosed>
<isDeleted>false</isDeleted>
</thread>
<post dsq:id="299619020">
<id>3565798471341011339</id>
<message>
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
</message>
<createdAt>2011-08-31T15:16:29Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email/>
<name>Alexander Blah</name>
<isAnonymous>false</isAnonymous>
<username>facebook-1787732238</username>
</author>
<ipAddress>178.178.178.178</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299744309">
<id>3029154520436241933</id>
<message>
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
</message>
<createdAt>2011-08-31T17:44:22Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>mihail.noname@gmail.com</email>
<name>mikhail</name>
<isAnonymous>false</isAnonymous>
<username>mikhail-noname</username>
</author>
<ipAddress>195.195.195.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="299986072">
<id>6580890074280459209</id>
<message>
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
</message>
<createdAt>2011-08-31T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post>
<id>12345678890</id>
<message>This comment had no ID</message>
<createdAt>2011-08-31T22:49:43Z</createdAt>
<forum>radiot</forum>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>blah.noname@gmail.com</email>
<name>Blah Noname</name>
<isAnonymous>false</isAnonymous>
<username>74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
</post>
<post dsq:id="299986073">
<id>6580890074280459219</id>
<message>some ugly spam</message>
<createdAt>2011-09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>true</isSpam>
<author>
<email>spam.noname@gmail.com</email>
<name>Spam Noname</name>
<isAnonymous>false</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.139</ipAddress>
<thread dsq:id="247937687"/>
</post>
<post dsq:id="x299986073">
<message>some bad comment</message>
<createdAt>2011-x09-30T22:48:43Z</createdAt>
<isDeleted>false</isDeleted>
<isSpam>123</isSpam>
<author>
<email>noname@gmail.com</email>
<name>Noname</name>
<isAnonymous>true</isAnonymous>
<username>google-2c5d77590123</username>
</author>
<ipAddress>189.89.89.39</ipAddress>
<thread dsq:id=247937687/>
</post>
</disqus>
+5 -5
View File
@@ -9,7 +9,7 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
const wpTimeLayout = "2006-01-02 15:04:05"
@@ -61,8 +61,8 @@ func (w *WordPress) Convert(text string) string {
// Import comments from WP and save to store
func (w *WordPress) Import(r io.Reader, siteID string) (size int, err error) {
if err = w.DataStore.DeleteAll(siteID); err != nil {
return 0, err
if e := w.DataStore.DeleteAll(siteID); e != nil {
return 0, e
}
commentsCh := w.convert(r, siteID)
@@ -107,8 +107,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
break
}
switch el := t.(type) {
case xml.StartElement:
if el, ok := t.(xml.StartElement); ok {
if el.Name.Local == "item" {
stats.inpItems++
item := wpItem{}
@@ -139,6 +138,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
Text: comment.Content,
Timestamp: comment.Date.time,
ParentID: comment.PID,
Imported: true,
}
commentsCh <- commentFormatter.Format(c)
stats.inpComments++
+6 -4
View File
@@ -10,10 +10,10 @@ import (
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestWordPress_Import(t *testing.T) {
@@ -42,6 +42,7 @@ func TestWordPress_Import(t *testing.T) {
ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14")
assert.Equal(t, ts, c.Timestamp)
assert.Equal(t, c.Text, "<p>Mekkatorque was over in that tent up to the right</p>\n")
assert.True(t, c.Imported)
posts, err := dataStore.List(siteID, 0, 0)
assert.NoError(t, err)
@@ -77,6 +78,7 @@ func TestWordPress_Convert(t *testing.T) {
ID: "wordpress_" + store.EncodeID("Wednesday Reading &laquo; Cynwise&#039;s Battlefield Manual"),
IP: "74.200.244.101",
},
Imported: true,
}
exp1.Timestamp, _ = time.Parse(wpTimeLayout, "2010-07-21 14:02:08")
assert.Equal(t, exp1, comments[1])
+108 -153
View File
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"fmt"
"io"
"mime"
"mime/quotedprintable"
"net"
"net/smtp"
@@ -14,17 +15,21 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/templates"
)
// EmailParams contain settings for email notifications
type EmailParams struct {
From string // from email address
MsgTemplate string // request message template
VerificationSubject string // verification message subject
VerificationTemplate string // verification message template
SubscribeURL string // full subscribe handler URL
UnsubscribeURL string // full unsubscribe handler URL
From string // from email address
AdminEmails []string // administrator emails to send copy of comment notification to
MsgTemplatePath string // path to request message template
VerificationSubject string // verification message sub
VerificationTemplatePath string // path to verification template
SubscribeURL string // full subscribe handler URL
UnsubscribeURL string // full unsubscribe handler URL
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
}
@@ -101,174 +106,123 @@ type verifyTmplData struct {
}
const (
defaultVerificationSubject = "Email verification"
defaultEmailTimeout = 10 * time.Second
defaultEmailTemplate = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style type="text/css">
img {
max-width: 100%;
max-height: 250px;
margin: 5px 0;
display: block;
color: #000;
}
a {
text-decoration: none;
color: #0aa;
}
p {
margin: 0 0 12px;
}
blockquote {
margin: 10px 0;
padding: 12px 12px 1px 12px;
background: rgba(255,255,255,.5)
}
</style>
</head>
<!-- Some of blocks on this page have color: #000 because GMail can wrap block in his own tags which can change text color -->
<body>
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 18px; width: 100%; max-width: 640px; margin: auto;">
<h1 style="text-align: center; position: relative; color: #4fbbd6; margin-top: 10px; margin-bottom: 10px;">Remark42</h1>
{{- if .ForAdmin}}
<div style="font-size: 16px; text-align: center; margin-bottom: 10px; color:#000!important;">New comment from {{.UserName}} on your site {{if .PostTitle}} to «{{.PostTitle}}»{{ end }}</div>
{{- else }}
<div style="font-size: 16px; text-align: center; margin-bottom: 10px; color:#000!important;">New reply from {{.UserName}} on your comment{{if .PostTitle}} to «{{.PostTitle}}»{{ end }}</div>
{{- end }}
<div style="background-color: #eee; padding: 15px 20px 20px 20px; border-radius: 3px;">
{{- if .ParentCommentText}}
<div style="margin-bottom: 12px; line-height: 24px; word-break: break-all;">
<img src="{{.ParentUserPicture}}" style="width: 24px; height: 24px; display: inline; vertical-align: middle; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
<span style="font-size: 14px; font-weight: bold; color: #777">{{.ParentUserName}}</span>
<span style="color: #999; font-size: 14px; margin: 0 8px;">{{.ParentCommentDate.Format "02.01.2006 at 15:04"}}</span>
<a href="{{.ParentCommentLink}}" style="color: #0aa; font-size: 14px;"><b>Show</b></a>
</div>
<div style="font-size: 14px; color:#333!important; padding: 0 14px 0 2px; border-radius: 3px; line-height: 1.4;">
{{.ParentCommentText}}
</div>
{{- end }}
<div style="padding-left: 20px; border-left: 1px dotted rgba(0,0,0,0.15); margin-top: 15px; padding-top: 5px;">
<div style="margin-bottom: 12px;" line-height: 24px;word-break: break-all;>
<img src="{{.UserPicture}}" style="width: 24px; height: 24px; display:inline; vertical-align:middle; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
<span style="font-size: 14px; font-weight: bold; color: #777">{{.UserName}}</span>
<span style="color: #999; font-size: 14px; margin: 0 8px;">{{.CommentDate.Format "02.01.2006 at 15:04"}}</span>
<a href="{{.CommentLink}}" style="color: #0aa; font-size: 14px;"><b>Reply</b></a>
</div>
<div style="font-size: 16px; background-color: #fff; color:#000!important; padding: 14px 14px 2px 14px; border-radius: 3px; line-height: 1.4;">{{.CommentText}}</div>
</div>
</div>
<div style="text-align: center; font-size: 14px; margin-top: 32px;">
<i style="color: #000!important;">Sent to <a style="color:inherit; text-decoration: none" href="mailto:{{.Email}}">{{.Email}}</a>{{if not .ForAdmin}} for {{.ParentUserName}}{{ end }}</i>
<div style="margin: auto; width: 150px; border-top: 1px solid rgba(0, 0, 0, 0.15); padding-top: 15px; margin-top: 15px;"></div>
{{- if .UnsubscribeLink}}
<a style="color: #0aa;" href="{{.UnsubscribeLink}}">Unsubscribe</a>
{{- end }}
<!-- This is hack for remove collapser in Gmail which can collapse end of the message -->
<div style="opacity: 0;font-size: 1;">[{{.CommentDate.Format "02.01.2006 at 15:04"}}]</div>
</div>
</div>
</body>
</html>
`
defaultEmailVerificationTemplate = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<!-- Some of blocks on this page have color: #000 because GMail can wrap block in his own tags which can change text color -->
<div style="text-align: center; font-family: Helvetica, Arial, sans-serif; font-size: 18px;">
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em; color:#000!important;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
{{- if .SubscribeURL}}
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;"><a href="{{.SubscribeURL}}{{.Token}}">Click here to subscribe to email notifications</a></p>
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;">Alternatively, you can use code below for subscription.</p>
{{- end }}
<div style="background-color: #eee; max-width: 20em; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;">TOKEN</p>
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i style="color:#000!important;">Copy and paste this text into token field on comments page</i></p>
<p style="position: relative; font-family: monospace; background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; text-align: left; border-radius: 0.2em; -webkit-user-select: all; user-select: all;">{{.Token}}</p>
</div>
<p style="position: relative; margin-top: 2em; font-size: 0.8em; opacity: 0.8;"><i style="color:#000!important;">Sent to {{.Email}}</i></p>
</div>
</body>
</html>
`
defaultVerificationSubject = "Email verification"
defaultEmailTimeout = 10 * time.Second
defaultEmailTemplatePath = "email_reply.html.tmpl"
defaultEmailVerificationTemplatePath = "email_confirmation_subscription.html.tmpl"
)
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
func NewEmail(emailParams EmailParams, smtpParams SMTPParams) (*Email, error) {
// set up Email emailParams
res := Email{EmailParams: emailParams}
if res.MsgTemplate == "" {
res.MsgTemplate = defaultEmailTemplate
}
if res.VerificationTemplate == "" {
res.VerificationTemplate = defaultEmailVerificationTemplate
}
if res.VerificationSubject == "" {
res.VerificationSubject = defaultVerificationSubject
}
// set up SMTP emailParams
res.smtp = &emailClient{}
res.SMTPParams = smtpParams
if res.TimeOut <= 0 {
res.TimeOut = defaultEmailTimeout
}
if res.VerificationSubject == "" {
res.VerificationSubject = defaultVerificationSubject
}
// initialize templates
err := res.setTemplates()
if err != nil {
return nil, errors.Wrap(err, "can't set templates")
}
log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
res.Host, res.Username, res.TimeOut)
// initialize templates
var err error
if res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate); err != nil {
return nil, errors.Wrapf(err, "can't parse message template")
}
if res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate); err != nil {
return nil, errors.Wrapf(err, "can't parse verification template")
}
return &res, err
return &res, nil
}
// Send email about comment reply to Request.Email if it's set,
// also sends email to site administrator if appropriate option is set.
func (e *Email) setTemplates() error {
var err error
var msgTmplFile, verifyTmplFile []byte
fs := templates.NewFS()
if e.VerificationTemplatePath == "" {
e.VerificationTemplatePath = defaultEmailVerificationTemplatePath
}
if e.MsgTemplatePath == "" {
e.MsgTemplatePath = defaultEmailTemplatePath
}
if msgTmplFile, err = fs.ReadFile(e.MsgTemplatePath); err != nil {
return errors.Wrapf(err, "can't read message template")
}
if verifyTmplFile, err = fs.ReadFile(e.VerificationTemplatePath); err != nil {
return errors.Wrapf(err, "can't read verification template")
}
if e.msgTmpl, err = template.New("msgTmpl").Parse(string(msgTmplFile)); err != nil {
return errors.Wrapf(err, "can't parse message template")
}
if e.verifyTmpl, err = template.New("verifyTmpl").Parse(string(verifyTmplFile)); err != nil {
return errors.Wrapf(err, "can't parse verification template")
}
return nil
}
// Send email about comment reply to Request.Emails and Email.AdminEmails
// if they're set.
// Thread safe
func (e *Email) Send(ctx context.Context, req Request) (err error) {
func (e *Email) Send(ctx context.Context, req Request) error {
select {
case <-ctx.Done():
return errors.Errorf("sending email messages about comment %q aborted due to canceled context", req.Comment.ID)
default:
}
result := new(multierror.Error)
for _, email := range req.Emails {
err := e.buildAndSendMessage(ctx, req, email, false)
result = multierror.Append(errors.Wrapf(err, "problem sending user email notification to %q", email))
}
for _, email := range e.AdminEmails {
err := e.buildAndSendMessage(ctx, req, email, true)
result = multierror.Append(errors.Wrapf(err, "problem sending admin email notification to %q", email))
}
return result.ErrorOrNil()
}
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
msg, err := e.buildMessageFromRequest(req, email, forAdmin)
if err != nil {
return err
}
return repeater.NewDefault(5, time.Millisecond*250).Do(
ctx,
func() error {
return e.sendMessage(emailMessage{from: e.From, to: email, message: msg})
})
}
// SendVerification email verification VerificationRequest.Email if it's set.
// Thread safe
func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) error {
if req.Email == "" {
// this means we can't send this request via Email
return nil
}
select {
case <-ctx.Done():
return errors.Errorf("sending message to %q aborted due to canceled context", req.Email)
return errors.Errorf("sending message to %q aborted due to canceled context", req.User)
default:
}
var msg string
if req.Verification.Token != "" {
log.Printf("[DEBUG] send verification via %s, user %s", e, req.Verification.User)
msg, err = e.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.SiteID)
if err != nil {
return err
}
}
if req.Comment.ID != "" {
if req.parent.User.ID == req.Comment.User.ID && !req.ForAdmin {
// don't send anything if if user replied to their own comment
return nil
}
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
msg, err = e.buildMessageFromRequest(req, req.ForAdmin)
if err != nil {
return err
}
log.Printf("[DEBUG] send verification via %s, user %s", e, req.User)
msg, err := e.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
if err != nil {
return err
}
return repeater.NewDefault(5, time.Millisecond*250).Do(
@@ -296,16 +250,16 @@ func (e *Email) buildVerificationMessage(user, email, token, site string) (strin
}
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
func (e *Email) buildMessageFromRequest(req Request, forAdmin bool) (string, error) {
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (string, error) {
subject := "New reply to your comment"
if forAdmin {
subject = "New comment to your site"
}
if req.Comment.PostTitle != "" {
subject += fmt.Sprintf(" for \"%s\"", req.Comment.PostTitle)
subject += fmt.Sprintf(" for %q", req.Comment.PostTitle)
}
token, err := e.TokenGenFn(req.parent.User.ID, req.Email, req.Comment.Locator.SiteID)
token, err := e.TokenGenFn(req.parent.User.ID, email, req.Comment.Locator.SiteID)
if err != nil {
return "", errors.Wrapf(err, "error creating token for unsubscribe link")
}
@@ -323,7 +277,7 @@ func (e *Email) buildMessageFromRequest(req Request, forAdmin bool) (string, err
CommentLink: commentURLPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp,
PostTitle: req.Comment.PostTitle,
Email: req.Email,
Email: email,
UnsubscribeLink: unsubscribeLink,
ForAdmin: forAdmin,
}
@@ -339,7 +293,7 @@ func (e *Email) buildMessageFromRequest(req Request, forAdmin bool) (string, err
if err != nil {
return "", errors.Wrapf(err, "error executing template to build comment reply message")
}
return e.buildMessage(subject, msg.String(), req.Email, "text/html", unsubscribeLink)
return e.buildMessage(subject, msg.String(), email, "text/html", unsubscribeLink)
}
// buildMessage generates email message to send using net/smtp.Data()
@@ -350,7 +304,7 @@ func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink str
}
message = addHeader(message, "From", e.From)
message = addHeader(message, "To", to)
message = addHeader(message, "Subject", subject)
message = addHeader(message, "Subject", mime.BEncoding.Encode("utf-8", subject))
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
if contentType != "" {
@@ -451,6 +405,7 @@ func (s *emailClient) Create(params SMTPParams) (smtpClient, error) {
tlsConf := &tls.Config{
InsecureSkipVerify: false,
ServerName: params.Host,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", srvAddress, tlsConf)
if err != nil {
+166 -97
View File
@@ -12,84 +12,92 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
func TestEmailNew(t *testing.T) {
var testSet = []struct {
emailParams := EmailParams{
From: "test@from",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}
smtpParams := SMTPParams{
Host: "test@host",
Port: 1000,
TLS: true,
Username: "test@username",
Password: "test@password",
TimeOut: time.Second,
}
email, err := NewEmail(emailParams, smtpParams)
assert.NoError(t, err)
assert.NotNil(t, email, "email returned")
assert.NotNil(t, email.msgTmpl, "e.template is set")
assert.Equal(t, emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
if smtpParams.TimeOut == 0 {
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
} else {
assert.Equal(t, smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
}
assert.Equal(t, smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
assert.Equal(t, smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
assert.Equal(t, smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
assert.Equal(t, smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
assert.Equal(t, smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
}
func Test_initTemplatesErr(t *testing.T) {
testSet := []struct {
name string
err bool
errText string
emailParams EmailParams
smtpParams SMTPParams
}{
{name: "empty"},
{name: "with template parse error",
err: true, errText: "can't parse message template: template: messageFromRequest:1: unexpected unclosed action in command",
{
name: "with wrong path to verification template",
errText: "can't read verification template: open notfount.tmpl: no such file or directory",
emailParams: EmailParams{
MsgTemplate: "{{",
}},
{name: "with verification template parse error",
err: true, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
emailParams: EmailParams{
From: "test@from",
VerificationTemplate: "{{",
},
smtpParams: SMTPParams{
Host: "test@host",
Port: 1000,
TLS: true,
Username: "test@username",
Password: "test@password",
TimeOut: time.Second,
VerificationTemplatePath: "notfount.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
},
},
{name: "normal creation",
err: false, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
{
name: "with wrong path to message template",
errText: "can't read message template: open notfount.tmpl: no such file or directory",
emailParams: EmailParams{
From: "test@from",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "notfount.tmpl",
},
smtpParams: SMTPParams{
Host: "test@host",
Port: 1000,
TLS: true,
Username: "test@username",
Password: "test@password",
TimeOut: time.Second,
},
{
name: "with error on read verification template",
errText: "can't parse verification template: template: verifyTmpl",
emailParams: EmailParams{
VerificationTemplatePath: "testdata/bad.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
},
},
{
name: "with error on read message template",
errText: "can't parse message template: template: msgTmpl",
emailParams: EmailParams{
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/bad.html.tmpl",
},
},
}
for _, d := range testSet {
d := d
t.Run(d.name, func(t *testing.T) {
email, err := NewEmail(d.emailParams, d.smtpParams)
if d.err && d.errText == "" {
assert.Error(t, err)
assert.Nil(t, email)
} else if d.err && d.errText != "" {
assert.EqualError(t, err, d.errText)
assert.Nil(t, email)
} else {
assert.NoError(t, err)
assert.NotNil(t, email, "email returned")
assert.NotNil(t, email.msgTmpl, "e.template is set")
assert.Equal(t, defaultEmailTemplate, email.EmailParams.MsgTemplate, "empty emailParams.MsgTemplate changed to default")
assert.Equal(t, defaultEmailVerificationTemplate, email.EmailParams.VerificationTemplate, "empty emailParams.VerificationTemplate changed to default")
assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
if d.smtpParams.TimeOut == 0 {
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
} else {
assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
}
assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
}
e := Email{EmailParams: d.emailParams}
err := e.setTemplates()
require.Error(t, err)
assert.Contains(t, err.Error(), d.errText)
})
}
}
@@ -101,41 +109,39 @@ func TestEmailSendErrors(t *testing.T) {
e.verifyTmpl, err = template.New("test").Parse("{{.Test}}")
assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Email: "bad@example.org", Verification: VerificationMetadata{Token: "some"}}),
assert.EqualError(t, e.SendVerification(context.Background(), VerificationRequest{Email: "bad@example.org", Token: "some"}),
"error executing template to build verification message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData")
e.verifyTmpl, err = template.New("test").Parse(defaultEmailVerificationTemplate)
assert.NoError(t, err)
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
"error executing template to build comment reply message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData")
e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate)
assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\": "+
"error executing template to build comment reply message: "+
"template: test:1:2: executing \"test\" at <.Test>: "+
"can't evaluate field Test in type notify.msgTmplData\n\n")
ctx, cancel := context.WithCancel(context.Background())
cancel()
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
"sending message to \"bad@example.org\" aborted due to canceled context")
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
"sending email messages about comment \"999\" aborted due to canceled context")
e.smtp = &fakeTestSMTP{}
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Email: "bad@example.org"}),
"error creating token for unsubscribe link: token generation error")
e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate)
assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad@example.org"}}),
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\":"+
" error creating token for unsubscribe link: token generation error\n\n")
}
func TestEmailSend_ExitConditions(t *testing.T) {
email, err := NewEmail(EmailParams{}, SMTPParams{})
email, err := NewEmail(EmailParams{
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email, "expecting email returned")
// prevent triggering e.autoFlush creation
emptyRequest := Request{Comment: store.Comment{ID: "999"}}
assert.NoError(t, email.Send(context.Background(), emptyRequest),
"Message without parent comment User.Email is not sent and returns nil")
requestWithEqualUsersWithEmails := Request{Comment: store.Comment{ID: "999"}, Email: "good_example@example.org"}
assert.NoError(t, email.Send(context.Background(), requestWithEqualUsersWithEmails),
"Message with parent comment User equals comment User is not sent and returns nil")
"Message without Emails and AdminEmails is not sent and returns nil")
}
func TestEmailSendClientError(t *testing.T) {
@@ -180,8 +186,21 @@ func TestEmailSendClientError(t *testing.T) {
"e.send called without smtpClient set returns error")
}
func TestEmail_DefaultTemplates(t *testing.T) {
email, err := NewEmail(EmailParams{}, SMTPParams{})
assert.Error(t, err)
assert.Nil(t, email)
email, err = NewEmail(EmailParams{VerificationTemplatePath: "testdata/verification.html.tmpl"}, SMTPParams{})
assert.Error(t, err)
assert.Nil(t, email)
}
func TestEmail_Send(t *testing.T) {
email, err := NewEmail(EmailParams{From: "from@example.org"}, SMTPParams{})
email, err := NewEmail(EmailParams{
From: "from@example.org",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSMTP := fakeTestSMTP{}
@@ -191,14 +210,14 @@ func TestEmail_Send(t *testing.T) {
req := Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, ParentID: "1", PostTitle: "test_title"},
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
Email: "test@example.org",
Emails: []string{"test@example.org"},
}
assert.NoError(t, email.Send(context.TODO(), req))
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
assert.Equal(t, 1, fakeSMTP.readQuitCount())
assert.Equal(t, "test@example.org", fakeSMTP.readRcpt())
// test buildMessageFromRequest separately for message text
res, err := email.buildMessageFromRequest(req, req.ForAdmin)
res, err := email.buildMessageFromRequest(req, req.Emails[0], false)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
@@ -210,14 +229,17 @@ List-Unsubscribe-Post: List-Unsubscribe=One-Click
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
Date: `)
// send email to admin without parent set
// send email to both user and admin, without parent set
email.AdminEmails = []string{"admin@example.org"}
req = Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
Email: "admin@example.org",
ForAdmin: true,
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
Emails: []string{"test@example.org"},
}
assert.NoError(t, email.Send(context.TODO(), req))
res, err = email.buildMessageFromRequest(req, req.ForAdmin)
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
assert.Equal(t, 3, fakeSMTP.readQuitCount(), "plus two emails: one for user and one for admin")
assert.Equal(t, "admin@example.org", fakeSMTP.readRcpt())
res, err = email.buildMessageFromRequest(req, email.AdminEmails[0], true)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: admin@example.org
@@ -228,27 +250,74 @@ Content-Type: text/html; charset="UTF-8"
Date: `)
}
func TestEmail_SendVerification(t *testing.T) {
email, err := NewEmail(EmailParams{From: "from@example.org"}, SMTPParams{})
func TestEmail_SendWithUnicodeInSubject(t *testing.T) {
email, err := NewEmail(EmailParams{
From: "from@example.org",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSMTP := fakeTestSMTP{}
email.smtp = &fakeSMTP
email.TokenGenFn = TokenGenFn
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
req := Request{
Email: "test@example.org",
Verification: VerificationMetadata{
SiteID: "remark",
User: "test_username",
Token: "secret_",
},
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, ParentID: "1", PostTitle: "Привет"},
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
Emails: []string{"test@example.org"},
}
assert.NoError(t, email.Send(context.TODO(), req))
// test buildMessageFromRequest separately for message text
res, err := email.buildMessageFromRequest(req, req.Emails[0], false)
assert.NoError(t, err)
// `=?utf-8?b?TmV3IHJlcGx5IHRvIHlvdXIgY29tbWVudCBmb3IgItCf0YDQuNCy0LXRgiI=?=` -> `New reply to your comment for "Привет"` in base64 + required prefix and suffix
assert.Contains(t, res, `From: from@example.org
To: test@example.org
Subject: =?utf-8?b?TmV3IHJlcGx5IHRvIHlvdXIgY29tbWVudCBmb3IgItCf0YDQuNCy0LXRgiI=?=
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
List-Unsubscribe-Post: List-Unsubscribe=One-Click
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
Date: `)
}
func TestEmail_SendVerification(t *testing.T) {
email, err := NewEmail(EmailParams{
From: "from@example.org",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSMTP := fakeTestSMTP{}
email.smtp = &fakeSMTP
email.TokenGenFn = TokenGenFn
// proper VerificationRequest without email
req := VerificationRequest{
SiteID: "remark",
User: "test_username",
Token: "secret_",
}
assert.NoError(t, email.SendVerification(context.TODO(), req))
assert.Equal(t, "", fakeSMTP.readMail())
assert.Equal(t, 0, fakeSMTP.readQuitCount())
assert.Equal(t, "", fakeSMTP.readRcpt())
// proper VerificationRequest with email
req.Email = "test@example.org"
assert.NoError(t, email.SendVerification(context.TODO(), req))
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
assert.Equal(t, 1, fakeSMTP.readQuitCount())
assert.Equal(t, "test@example.org", fakeSMTP.readRcpt())
// test buildMessageFromRequest separately for message text
res, err := email.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.SiteID)
// VerificationRequest with canceled context
ctx, cancel := context.WithCancel(context.TODO())
cancel()
assert.EqualError(t, email.SendVerification(ctx, req), "sending message to \"test_username\" aborted due to canceled context")
// test buildVerificationMessage separately for message text
res, err := email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
@@ -260,7 +329,7 @@ Date: `)
assert.Contains(t, res, `secret_`)
assert.NotContains(t, res, `https://example.org/`)
email.SubscribeURL = "https://example.org/subscribe.html?token="
res, err = email.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.SiteID)
res, err = email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
+119 -40
View File
@@ -9,14 +9,15 @@ import (
log "github.com/go-pkgz/lgr"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
// Service delivers notifications to multiple destinations
type Service struct {
dataService Store
destinations []Destination
queue chan Request
dataService Store
destinations []Destination
queue chan Request
verificationQueue chan VerificationRequest
closed uint32 // non-zero means closed. uses uint instead of bool for atomic
ctx context.Context
@@ -26,29 +27,33 @@ type Service struct {
// Destination defines interface for a given destination service, like telegram, email and so on
type Destination interface {
fmt.Stringer
Send(ctx context.Context, req Request) error
Send(context.Context, Request) error
SendVerification(context.Context, VerificationRequest) error
}
// Store defines the minimal interface accessing stored comments used by notifier
type Store interface {
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
GetUserEmail(siteID string, userID string) (string, error)
GetUserTelegram(siteID string, userID string) (string, error)
}
// Request notification either about comment or about particular user verification
// used for email and telegram retrieval from user details
type getUserDetail func(string, string) (string, error)
// Request notification for a Comment
type Request struct {
Comment store.Comment // if set sent notifications about new comment
parent store.Comment // fetched only in case Comment is set
Email string // if set (also) send email
ForAdmin bool // if set, message supposed to be sent to administrator
Verification VerificationMetadata // if set sent verification notification
Comment store.Comment
parent store.Comment
Emails []string
Telegrams []string
}
// VerificationMetadata required to send notify method verification message
type VerificationMetadata struct {
// VerificationRequest notification for user
type VerificationRequest struct {
SiteID string
User string
Email string // if set, send email only
Token string
}
@@ -62,11 +67,12 @@ func NewService(dataService Store, size int, destinations ...Destination) *Servi
}
ctx, cancel := context.WithCancel(context.Background())
res := Service{
dataService: dataService,
queue: make(chan Request, size),
destinations: destinations,
ctx: ctx,
cancel: cancel,
dataService: dataService,
queue: make(chan Request, size),
verificationQueue: make(chan VerificationRequest, size),
destinations: destinations,
ctx: ctx,
cancel: cancel,
}
if len(destinations) > 0 {
go res.do()
@@ -80,18 +86,11 @@ func (s *Service) Submit(req Request) {
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
return
}
// parent comment is fetched only if comment is present in the Request
if s.dataService != nil && req.Comment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil {
req.parent = p
// user notification, should fetch email for it.
// administrator notification comes with pre-set email
if req.Email == "" {
req.Email, err = s.dataService.GetUserEmail(req.Comment.Locator.SiteID, p.User.ID)
if err != nil {
log.Printf("[WARN] can't read email for %s, %v", p.User.ID, err)
}
}
req.Emails = s.getNotificationTargets(req, p, s.dataService.GetUserEmail)
req.Telegrams = s.getNotificationTargets(req, p, s.dataService.GetUserTelegram)
}
}
select {
@@ -101,11 +100,52 @@ func (s *Service) Submit(req Request) {
}
}
// getNotificationTargets returns list of notification targets (like email or telegram username) for users
// interested in notifications for provided comment.
// Targets are not added to the returned list in case the original message
// is from the same user as the notification receiver.
// Results are deduplicated.
func (s *Service) getNotificationTargets(
req Request,
notifyComment store.Comment,
getUserDetail getUserDetail,
) (result []string) {
// add current user email only if the user is not the one who wrote the original comment
if notifyComment.User.ID != req.Comment.User.ID {
detail, err := getUserDetail(req.Comment.Locator.SiteID, notifyComment.User.ID)
if err != nil {
log.Printf("[WARN] can't read notification detail for %s, %v", notifyComment.User.ID, err)
}
if detail != "" {
result = append(result, detail)
}
}
if notifyComment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, notifyComment.ParentID, store.User{}); err == nil {
result = append(result, s.getNotificationTargets(req, p, getUserDetail)...)
}
}
return deduplicateStrings(result)
}
// SubmitVerification to internal channel if not busy, drop if can't send
func (s *Service) SubmitVerification(req VerificationRequest) {
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
return
}
select {
case s.verificationQueue <- req:
default:
log.Printf("[WARN] can't send verification to queue, %s for %s", req.User, req.Email)
}
}
// Close queue channel and wait for completion
func (s *Service) Close() {
if s.queue != nil {
log.Print("[DEBUG] close notifier")
close(s.queue)
close(s.verificationQueue)
s.cancel()
<-s.ctx.Done()
}
@@ -113,21 +153,60 @@ func (s *Service) Close() {
}
func (s *Service) do() {
for c := range s.queue {
var wg sync.WaitGroup
wg.Add(len(s.destinations))
for _, dest := range s.destinations {
go func(d Destination) {
if err := d.Send(s.ctx, c); err != nil {
log.Printf("[WARN] failed to send to %s, %s", d, err)
}
wg.Done()
}(dest)
defer log.Print("[WARN] terminated notifier")
var wg sync.WaitGroup
for {
select {
case c, ok := <-s.queue:
if !ok {
return
}
wg.Add(len(s.destinations))
for _, dest := range s.destinations {
go func(d Destination) {
if err := d.Send(s.ctx, c); err != nil {
log.Printf("[WARN] failed to send to %s, %s", d, err)
}
wg.Done()
}(dest)
}
wg.Wait()
case v, ok := <-s.verificationQueue:
if !ok {
return
}
wg.Add(len(s.destinations))
for _, dest := range s.destinations {
go func(d Destination) {
if err := d.SendVerification(s.ctx, v); err != nil {
log.Printf("[WARN] failed to send to %s, %s", d, err)
}
wg.Done()
}(dest)
}
wg.Wait()
case <-s.ctx.Done():
return
}
wg.Wait()
}
log.Print("[WARN] terminated notifier")
}
// NopService is do-nothing notifier, without destinations
var NopService = &Service{}
// deduplicateStrings returns provided slice of strings will all duplicates removed.
// Resulting slice is not sorted.
func deduplicateStrings(source []string) []string {
set := make(map[string]struct{})
for _, k := range source {
set[k] = struct{}{}
}
result := make([]string, 0, len(set))
for k := range set {
result = append(result, k)
}
return result
}
+30 -4
View File
@@ -11,10 +11,11 @@ import (
// MockDest is a destination mock
type MockDest struct {
data []Request
id int
closed bool
lock sync.Mutex
data []Request
verificationData []VerificationRequest
id int
closed bool
lock sync.Mutex
}
// Send mock
@@ -32,6 +33,21 @@ func (m *MockDest) Send(ctx context.Context, r Request) error {
return nil
}
// SendVerification mock
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
m.lock.Lock()
defer m.lock.Unlock()
select {
case <-time.After(10 * time.Millisecond):
m.verificationData = append(m.verificationData, v)
log.Printf("sent verification %s -> %d", v.User, m.id)
case <-ctx.Done():
log.Printf("verification ctx closed %d", m.id)
m.closed = true
}
return nil
}
// Get mock
func (m *MockDest) Get() []Request {
m.lock.Lock()
@@ -40,4 +56,14 @@ func (m *MockDest) Get() []Request {
copy(res, m.data)
return res
}
// GetVerify mock
func (m *MockDest) GetVerify() []VerificationRequest {
m.lock.Lock()
defer m.lock.Unlock()
res := make([]VerificationRequest, len(m.verificationData))
copy(res, m.verificationData)
return res
}
func (m *MockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
+186 -8
View File
@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
func TestService_NoDestinations(t *testing.T) {
@@ -52,15 +52,42 @@ func TestService_WithDrops(t *testing.T) {
s.Submit(Request{Comment: store.Comment{ID: "100"}})
s.Submit(Request{Comment: store.Comment{ID: "101"}})
time.Sleep(time.Millisecond * 11)
s.Submit(Request{Comment: store.Comment{ID: "102"}})
time.Sleep(time.Millisecond * 11)
time.Sleep(time.Millisecond * 21)
s.Close()
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
assert.Equal(t, 2, len(d1.Get()), "one comment from three dropped from d1, got: %v", d1.Get())
assert.Equal(t, 2, len(d2.Get()), "one comment from three dropped from d2, got: %v", d2.Get())
assert.LessOrEqual(t, len(d1.Get()), 2, "at least one comment from three dropped from d1, got: %v", d1.Get())
assert.LessOrEqual(t, len(d2.Get()), 2, "at least one comment from three dropped from d2, got: %v", d2.Get())
}
func TestService_SubmitVerificationWithDrops(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
s.SubmitVerification(VerificationRequest{
SiteID: "remark",
User: "testUser",
Email: "test@example.org",
Token: "testToken",
})
s.SubmitVerification(VerificationRequest{})
s.SubmitVerification(VerificationRequest{})
time.Sleep(time.Millisecond * 21)
s.Close()
s.SubmitVerification(VerificationRequest{}) // safe to send after close
assert.LessOrEqual(t, len(d2.GetVerify()), 2, "one request from three dropped from d2, got: %v", d2.GetVerify())
verifyDest := d1.GetVerify()
require.LessOrEqual(t, len(verifyDest), 2, "one request from three dropped from d1, got: %v", verifyDest)
assert.Equal(t, "remark", verifyDest[0].SiteID)
assert.Equal(t, "testUser", verifyDest[0].User)
assert.Equal(t, "test@example.org", verifyDest[0].Email)
assert.Equal(t, "testToken", verifyDest[0].Token)
}
func TestService_Many(t *testing.T) {
@@ -70,16 +97,20 @@ func TestService_Many(t *testing.T) {
for i := 0; i < 10; i++ {
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
s.SubmitVerification(VerificationRequest{User: fmt.Sprintf("%d", 100+i)})
time.Sleep(time.Millisecond * time.Duration(rand.Int31n(20)))
}
s.Close()
time.Sleep(time.Millisecond * 10)
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1")
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2")
assert.True(t, d1.closed)
assert.True(t, d2.closed)
assert.Equal(t, "mock id=1, closed=true", d1.String())
}
func TestService_WithParent(t *testing.T) {
@@ -106,6 +137,138 @@ func TestService_WithParent(t *testing.T) {
assert.Equal(t, "", destRes[1].parent.ID)
}
func TestService_EmailRetrieval(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.userDetails["u1"] = "u1@example.com"
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
// one comment, one notification
s.Submit(Request{Comment: dataStore.data["p1"]})
time.Sleep(time.Millisecond * 110)
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
// reply to the first comment, same comment as one in original comment
s.Submit(Request{Comment: dataStore.data["p2"]})
time.Sleep(time.Millisecond * 110)
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.Empty(t, destRes[1].Emails, "u1 is not notified they are the one who left the comment")
// another reply to the first comment, another user
s.Submit(Request{Comment: dataStore.data["p3"]})
time.Sleep(time.Millisecond * 110)
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p1", destRes[2].parent.ID)
assert.Equal(t, "u1", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
// reply to the last comment by another user, should trigger email retrieval error
s.Submit(Request{Comment: dataStore.data["p4"]})
time.Sleep(time.Millisecond * 110)
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u2", destRes[3].parent.User.ID)
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
s.Close()
}
func TestService_Recursive(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
dataStore.userDetails["u1"] = "u1@example.com"
// second comment goes without email address for notification
dataStore.userDetails["u3"] = "u3@example.com"
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
// one comment from u1 with email set
s.Submit(Request{Comment: dataStore.data["p1"]})
time.Sleep(time.Millisecond * 110)
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
// reply to the first comment from u2 without email set
s.Submit(Request{Comment: dataStore.data["p2"]})
time.Sleep(time.Millisecond * 110)
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
// reply to the second comment from u3 with email set
s.Submit(Request{Comment: dataStore.data["p3"]})
time.Sleep(time.Millisecond * 110)
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p2", destRes[2].parent.ID)
assert.Equal(t, "u2", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
s.Submit(Request{Comment: dataStore.data["p4"]})
time.Sleep(time.Millisecond * 110)
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified once each")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u3", destRes[3].parent.User.ID)
assert.ElementsMatch(t, []string{"u3@example.com"}, destRes[3].Emails, "u1 is not notified they are the one who left the comment")
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
s.Submit(Request{Comment: dataStore.data["p5"]})
time.Sleep(time.Millisecond * 110)
destRes = dest.Get()
require.Equal(t, 5, len(destRes), "four comment notified once each")
assert.Equal(t, "p5", destRes[4].Comment.ID)
assert.Equal(t, "p4", destRes[4].parent.ID)
assert.Equal(t, "u1", destRes[4].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
s.Close()
}
func TestService_Nop(t *testing.T) {
s := NopService
s.Submit(Request{Comment: store.Comment{}})
@@ -113,7 +276,18 @@ func TestService_Nop(t *testing.T) {
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
}
type mockStore struct{ data map[string]store.Comment }
type mockStore struct {
data map[string]store.Comment
userDetails map[string]string
}
func (m mockStore) getUserDetail(userID string) (string, error) {
detail, ok := m.userDetails[userID]
if !ok {
return "", errors.New("no such user")
}
return detail, nil
}
func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) {
res, ok := m.data[id]
@@ -123,6 +297,10 @@ func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment,
return res, nil
}
func (m mockStore) GetUserEmail(_ string, _ string) (string, error) {
return "", errors.New("no such user")
func (m mockStore) GetUserEmail(_, userID string) (string, error) {
return m.getUserDetail(userID)
}
func (m mockStore) GetUserTelegram(_, userID string) (string, error) {
return m.getUserDetail(userID)
}
+101
View File
@@ -0,0 +1,101 @@
package notify
import (
"context"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/slack-go/slack"
)
// Slack implements notify.Destination for Slack
type Slack struct {
channelID string
channelName string
client *slack.Client
}
// NewSlack makes Slack bot for notifications
func NewSlack(token, channelName string, opts ...slack.Option) (*Slack, error) {
if channelName == "" {
channelName = "general"
}
client := slack.New(token, opts...)
res := &Slack{client: client, channelName: channelName}
channelID, err := res.findChannelIDByName(channelName)
if err != nil {
return nil, errors.Wrap(err, "can not find slack channel '"+channelName+"'")
}
res.channelID = channelID
log.Printf("[DEBUG] create new slack notifier for chan %s", channelID)
return res, nil
}
// Send to Slack channel
func (t *Slack) Send(ctx context.Context, req Request) error {
log.Printf("[DEBUG] send slack notification, comment id %s", req.Comment.ID)
user := req.Comment.User.Name
if req.Comment.ParentID != "" {
user += " → " + req.parent.User.Name
}
title := "↦ original comment"
if req.Comment.PostTitle != "" {
title = "↦ " + req.Comment.PostTitle
}
_, _, err := t.client.PostMessageContext(ctx, t.channelID,
slack.MsgOptionText("New comment from "+user, false),
slack.MsgOptionAttachments(
slack.Attachment{
TitleLink: req.Comment.Locator.URL + uiNav + req.Comment.ID,
Title: title,
Text: req.Comment.Orig,
},
),
)
return err
}
// SendVerification is not implemented for Slack
func (t *Slack) SendVerification(_ context.Context, _ VerificationRequest) error {
return nil
}
func (t *Slack) String() string {
return "slack: " + t.channelName + " (" + t.channelID + ")"
}
func (t *Slack) findChannelIDByName(name string) (string, error) {
params := slack.GetConversationsParameters{}
for {
chans, next, err := t.client.GetConversations(&params)
if err != nil {
return "", err
}
for _, channel := range chans {
if channel.Name == name {
return channel.ID, nil
}
}
if next == "" {
break
}
params.Cursor = next
}
return "", errors.New("no such channel")
}
+170
View File
@@ -0,0 +1,170 @@
package notify
import (
"context"
"log"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/slack-go/slack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
)
func TestSlack_New(t *testing.T) {
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "C12345678", tb.channelID)
_, err = ts.newClient("unknown-channel")
require.Error(t, err)
assert.Contains(t, err.Error(), "no such channel")
}
func TestSlack_Send(t *testing.T) {
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
c.User.Name = "from"
cp := store.Comment{Text: "some parent text"}
cp.User.Name = "to"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
c.PostTitle = "test title"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
c.PostTitle = "[test title]"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
tb, err = ts.newClient("general")
assert.NoError(t, err)
ts.isServerDown = true
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
require.Error(t, err)
assert.Contains(t, err.Error(), "slack server error", "send on broken client")
}
func TestSlack_Name(t *testing.T) {
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "slack: general (C12345678)", tb.String())
}
func TestSlack_SendVerification(t *testing.T) {
ts := newMockSlackServer()
defer ts.Close()
tb, err := ts.newClient("general")
assert.NoError(t, err)
assert.NotNil(t, tb)
err = tb.SendVerification(context.TODO(), VerificationRequest{})
assert.NoError(t, err)
}
type mockSlackServer struct {
*httptest.Server
isServerDown bool
}
func (ts *mockSlackServer) newClient(channelName string) (*Slack, error) {
return NewSlack("any-token", channelName, slack.OptionAPIURL(ts.URL+"/"))
}
func newMockSlackServer() *mockSlackServer {
mockServer := mockSlackServer{}
router := chi.NewRouter()
router.Post("/conversations.list", func(w http.ResponseWriter, r *http.Request) {
s := `{
"ok": true,
"channels": [
{
"id": "C12345678",
"name": "general",
"is_channel": true,
"is_group": false,
"is_im": false,
"created": 1503888888,
"is_archived": false,
"is_general": false,
"unlinked": 0,
"name_normalized": "random",
"is_shared": false,
"parent_conversation": null,
"creator": "U12345678",
"is_ext_shared": false,
"is_org_shared": false,
"pending_shared": [],
"pending_connected_team_ids": [],
"is_pending_ext_shared": false,
"is_member": false,
"is_private": false,
"is_mpim": false,
"previous_names": [],
"num_members": 1
}
],
"response_metadata": {
"next_cursor": ""
}
}`
_, _ = w.Write([]byte(s))
})
router.Post("/chat.postMessage", func(w http.ResponseWriter, r *http.Request) {
if mockServer.isServerDown {
w.WriteHeader(500)
} else {
s := `{
"ok": true,
"channel": "C12345678",
"ts": "1617008342.000100",
"message": {
"type": "message",
"subtype": "bot_message",
"text": "wowo",
"ts": "1617008342.000100",
"username": "slackbot",
"bot_id": "B12345678"
}
}`
_, _ = w.Write([]byte(s))
}
})
router.NotFound(func(w http.ResponseWriter, r *http.Request) {
log.Printf("..... 404 for %s .....\n", r.URL)
})
mockServer.Server = httptest.NewServer(router)
return &mockServer
}
+436 -95
View File
@@ -5,153 +5,494 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"golang.org/x/net/html"
"io"
"net/http"
neturl "net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/hashicorp/go-multierror"
"github.com/microcosm-cc/bluemonday"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater"
"github.com/pkg/errors"
)
// TelegramParams contain settings for telegram notifications
type TelegramParams struct {
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
Token string // token for telegram bot API interactions
Timeout time.Duration // http client timeout
UserNotifications bool // flag which enables user notifications
ErrorMsg, SuccessMsg string // messages for successful and unsuccessful subscription requests to bot
apiPrefix string // changed only in tests
}
// Telegram implements notify.Destination for telegram
type Telegram struct {
channelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
token string
apiPrefix string
timeout time.Duration
TelegramParams
// Identifier of the first update to be requested.
// Should be equal to LastSeenUpdateID + 1
// See https://core.telegram.org/bots/api#getupdates
updateOffset int
apiPollInterval time.Duration // interval to check updates from Telegram API and answer to users
expiredCleanupInterval time.Duration // interval to check and clean up expired notification requests
username string // bot username
run int32 // non-zero if Run goroutine has started
requests struct {
sync.RWMutex
data map[string]tgAuthRequest
}
}
// telegramMsg is used to send message trough Telegram bot API
type telegramMsg struct {
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
}
type tgAuthRequest struct {
confirmed bool // whether login request has been confirmed and user info set
expires time.Time
telegramID string
user string
site string
}
// TelegramBotInfo structure contains information about telegram bot, which is used from whole telegram API response
type TelegramBotInfo struct {
Username string `json:"username"`
}
const telegramTimeOut = 5000 * time.Millisecond
const telegramAPIPrefix = "https://api.telegram.org/bot"
const tgPollInterval = time.Second * 5
const tgCleanupInterval = time.Minute * 5
// NewTelegram makes telegram bot for notifications
func NewTelegram(token string, channelID string, timeout time.Duration, api string) (*Telegram, error) {
func NewTelegram(params TelegramParams) (*Telegram, error) {
res := Telegram{TelegramParams: params}
if _, err := strconv.ParseInt(channelID, 10, 64); err != nil {
channelID = "@" + channelID // if channelID not a number enforce @ prefix
}
res := Telegram{channelID: channelID, token: token, apiPrefix: api, timeout: timeout}
if res.apiPrefix == "" {
res.apiPrefix = telegramAPIPrefix
}
if res.timeout == 0 {
res.timeout = telegramTimeOut
if res.Timeout == 0 {
res.Timeout = telegramTimeOut
}
log.Printf("[DEBUG] create new telegram notifier for chan %s, timeout=%s, api=%s", channelID, res.timeout, res.timeout)
res.apiPollInterval = tgPollInterval
res.expiredCleanupInterval = tgCleanupInterval
log.Printf("[DEBUG] create new telegram notifier for api=%s, timeout=%s", res.apiPrefix, res.Timeout)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error {
client := http.Client{Timeout: telegramTimeOut}
resp, err := client.Get(fmt.Sprintf("%s%s/getMe", res.apiPrefix, token))
if err != nil {
return errors.Wrap(err, "can't initialize telegram notifications")
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("[WARN] can't close request body, %s", err)
}
}()
botInfo, err := res.botInfo(ctx)
if err != nil {
return nil, errors.Wrapf(err, "can't retrieve bot info from Telegram API")
}
res.username = botInfo.Username
if resp.StatusCode != http.StatusOK {
return errors.Errorf("unexpected telegram status code %d", resp.StatusCode)
}
res.requests.data = make(map[string]tgAuthRequest)
tgResp := struct {
OK bool `json:"ok"`
Result struct {
FirstName string `json:"first_name"`
ID uint64 `json:"id"`
IsBot bool `json:"is_bot"`
UserName string `json:"username"`
}
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
return errors.Wrap(err, "can't decode response")
}
if !tgResp.OK || !tgResp.Result.IsBot {
return errors.Errorf("unexpected telegram response %+v", tgResp)
}
return nil
})
return &res, err
return &res, nil
}
// Send to telegram channel
// Send to telegram recipients
func (t *Telegram) Send(ctx context.Context, req Request) error {
if req.Comment.ID == "" {
// verification request received, send nothing
return nil
log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID)
result := new(multierror.Error)
msg, err := buildMessage(req)
if err != nil {
return errors.Wrapf(err, "failed to make telegram message body for comment ID %s", req.Comment.ID)
}
if req.ForAdmin {
// request for administrator received, do nothing with it
// as we already sent message on request without this flag set
return nil
if t.AdminChannelID != "" {
err := t.sendMessage(ctx, msg, t.AdminChannelID)
result = multierror.Append(errors.Wrapf(err,
"problem sending admin telegram notification about comment ID %s to %s", req.Comment.ID, t.AdminChannelID),
)
}
client := http.Client{Timeout: telegramTimeOut}
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.Comment.ID)
if t.UserNotifications {
for _, user := range req.Telegrams {
err := t.sendMessage(ctx, msg, user)
result = multierror.Append(errors.Wrapf(err,
"problem sending user telegram notification about comment ID %s to %q", req.Comment.ID, user),
)
}
}
return result.ErrorOrNil()
}
func (t *Telegram) sendMessage(ctx context.Context, b []byte, chatID string) error {
if _, err := strconv.ParseInt(chatID, 10, 64); err != nil {
chatID = "@" + chatID // if chatID not a number enforce @ prefix
}
url := fmt.Sprintf("sendMessage?chat_id=%s&disable_web_page_preview=true", chatID)
return t.Request(ctx, url, b, &struct{}{})
}
// buildMessage generates message for generic notification about new comment
func buildMessage(req Request) ([]byte, error) {
commentURLPrefix := req.Comment.Locator.URL + uiNav
msg := fmt.Sprintf(`<a href=%q>%s</a>`, commentURLPrefix+req.Comment.ID, escapeTelegramText(req.Comment.User.Name))
from := req.Comment.User.Name
if req.Comment.ParentID != "" {
from += " → " + req.parent.User.Name
msg += fmt.Sprintf(" -> <a href=%q>%s</a>", commentURLPrefix+req.parent.ID, escapeTelegramText(req.parent.User.Name))
}
from = "*" + from + "*"
link := fmt.Sprintf("↦ [original comment](%s)", req.Comment.Locator.URL+uiNav+req.Comment.ID)
msg += fmt.Sprintf("\n\n%s", telegramSupportedHTML(req.Comment.Text))
if req.Comment.ParentID != "" {
msg += fmt.Sprintf("\n\n\"<i>%s</i>\"", telegramSupportedHTML(req.parent.Text))
}
if req.Comment.PostTitle != "" {
link = fmt.Sprintf("↦ [%s](%s)", req.Comment.PostTitle, req.Comment.Locator.URL+uiNav+req.Comment.ID)
msg += fmt.Sprintf("\n\n↦ <a href=%q>%s</a>", req.Comment.Locator.URL, escapeTelegramText(req.Comment.PostTitle))
}
u := fmt.Sprintf("%s%s/sendMessage?chat_id=%s&parse_mode=Markdown&disable_web_page_preview=true",
t.apiPrefix, t.token, t.channelID)
msg := fmt.Sprintf("%s\n\n%s\n\n%s", from, req.Comment.Orig, link)
msg = html.UnescapeString(msg)
body := struct {
Text string `json:"text"`
}{Text: msg}
body := telegramMsg{Text: msg, ParseMode: "HTML"}
b, err := json.Marshal(body)
if err != nil {
return errors.Wrap(err, "failed to make telegram body")
return nil, err
}
return b, nil
}
// returns HTML with only tags allowed in Telegram HTML message payload, also trims ending newlines
// https://core.telegram.org/bots/api#html-style
func telegramSupportedHTML(htmlText string) string {
adjustedHTMLText := adjustHTMLTags(htmlText)
p := bluemonday.NewPolicy()
p.AllowElements("b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "a", "code", "pre")
p.AllowAttrs("href").OnElements("a")
p.AllowAttrs("class").OnElements("code")
return strings.TrimRight(p.Sanitize(adjustedHTMLText), "\n")
}
// returns text sanitized of symbols not allowed inside other HTML tags in Telegram HTML message payload
// https://core.telegram.org/bots/api#html-style
func escapeTelegramText(text string) string {
// order is important
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
return text
}
// telegram not allow h1-h6 tags
// replace these tags with a combination of <b> and <i> for visual distinction
func adjustHTMLTags(htmlText string) string {
buff := strings.Builder{}
tokenizer := html.NewTokenizer(strings.NewReader(htmlText))
for {
if tokenizer.Next() == html.ErrorToken {
return buff.String()
}
token := tokenizer.Token()
switch token.Type {
case html.StartTagToken, html.EndTagToken:
switch token.Data {
case "h1", "h2", "h3":
if token.Type == html.StartTagToken {
buff.WriteString("<b>")
}
if token.Type == html.EndTagToken {
buff.WriteString("</b>")
}
case "h4", "h5", "h6":
if token.Type == html.StartTagToken {
buff.WriteString("<i><b>")
}
if token.Type == html.EndTagToken {
buff.WriteString("</b></i>")
}
default:
buff.WriteString(token.String())
}
default:
buff.WriteString(token.String())
}
}
}
// SendVerification is not needed for telegram
func (t *Telegram) SendVerification(_ context.Context, _ VerificationRequest) error {
return nil
}
// TelegramUpdate contains update information, which is used from whole telegram API response
type TelegramUpdate struct {
Result []struct {
UpdateID int `json:"update_id"`
Message struct {
Chat struct {
ID int `json:"id"`
Name string `json:"first_name"`
Type string `json:"type"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
} `json:"result"`
}
// GetBotUsername returns bot username
func (t *Telegram) GetBotUsername() string {
return t.username
}
// AddToken adds token
func (t *Telegram) AddToken(token, user, site string, expires time.Time) {
t.requests.Lock()
t.requests.data[token] = tgAuthRequest{
expires: expires,
user: user,
site: site,
}
t.requests.Unlock()
}
// CheckToken verifies incoming token, returns the user address if it's confirmed and empty string otherwise
func (t *Telegram) CheckToken(token, user string) (telegram, site string, err error) {
t.requests.RLock()
authRequest, ok := t.requests.data[token]
t.requests.RUnlock()
if !ok {
return "", "", errors.New("request is not found")
}
r, err := http.NewRequest("POST", u, bytes.NewReader(b))
if err != nil {
return errors.Wrap(err, "failed to make telegram request")
if time.Now().After(authRequest.expires) {
t.requests.Lock()
delete(t.requests.data, token)
t.requests.Unlock()
return "", "", errors.New("request expired")
}
r.Header.Set("Content-Type", "application/json; charset=utf-8")
r = r.WithContext(ctx)
resp, err := client.Do(r)
if err != nil {
return errors.Wrap(err, "failed to get telegram response")
if !authRequest.confirmed {
return "", "", errors.New("request is not verified yet")
}
if authRequest.user != user {
return "", "", errors.New("user does not match original requester")
}
// Delete request
t.requests.Lock()
delete(t.requests.data, token)
t.requests.Unlock()
return authRequest.telegramID, authRequest.site, nil
}
// Run starts processing login requests sent in Telegram, required for user notifications to work
// Blocks caller
func (t *Telegram) Run(ctx context.Context) {
atomic.AddInt32(&t.run, 1)
processUpdatedTicker := time.NewTicker(t.apiPollInterval)
cleanupTicker := time.NewTicker(t.expiredCleanupInterval)
for {
select {
case <-ctx.Done():
processUpdatedTicker.Stop()
cleanupTicker.Stop()
atomic.AddInt32(&t.run, -1)
return
case <-processUpdatedTicker.C:
updates, err := t.getUpdates(ctx)
if err != nil {
log.Printf("[WARN] Error while getting telegram updates: %v", err)
continue
}
t.processUpdates(ctx, updates)
case <-cleanupTicker.C:
now := time.Now()
t.requests.Lock()
for key, req := range t.requests.data {
if now.After(req.expires) {
delete(t.requests.data, key)
}
}
t.requests.Unlock()
}
}
}
// ProcessUpdate is alternative to Run, it processes provided plain text update from Telegram
// so that caller could get updates and send it not only there but to multiple sources
func (t *Telegram) ProcessUpdate(ctx context.Context, textUpdate string) error {
if atomic.LoadInt32(&t.run) != 0 {
return errors.New("Run goroutine should not be used with ProcessUpdate")
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("[WARN] can't close request body, %s", err)
// as Run goroutine is not running, clean up old requests on each update
// even if we hit json decode error
now := time.Now()
t.requests.Lock()
for key, req := range t.requests.data {
if now.After(req.expires) {
delete(t.requests.data, key)
}
}
t.requests.Unlock()
}()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("unexpected telegram status code %d for url %q", resp.StatusCode, u)
}
tgResp := struct {
OK bool `json:"ok"`
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
return errors.Wrap(err, "can't decode telegram response")
var updates TelegramUpdate
if err := json.Unmarshal([]byte(textUpdate), &updates); err != nil {
return errors.Wrap(err, "failed to decode provided telegram update")
}
t.processUpdates(ctx, &updates)
return nil
}
func (t *Telegram) String() string {
return "telegram: " + t.channelID
result := "telegram"
if t.AdminChannelID != "" {
result += " with admin notifications to " + t.AdminChannelID
}
if t.UserNotifications {
result += " with user notifications enabled"
}
return result
}
// getUpdates fetches incoming updates
func (t *Telegram) getUpdates(ctx context.Context) (*TelegramUpdate, error) {
url := `getUpdates?allowed_updates=["message"]`
if t.updateOffset != 0 {
url += fmt.Sprintf("&offset=%d", t.updateOffset)
}
var result TelegramUpdate
err := t.Request(ctx, url, nil, &result)
if err != nil {
return nil, errors.Wrap(err, "failed to fetch updates")
}
for _, u := range result.Result {
if u.UpdateID >= t.updateOffset {
t.updateOffset = u.UpdateID + 1
}
}
return &result, nil
}
// processUpdates processes a batch of updates from telegram servers
func (t *Telegram) processUpdates(ctx context.Context, updates *TelegramUpdate) {
for _, update := range updates.Result {
if update.Message.Chat.Type != "private" {
continue
}
if !strings.HasPrefix(update.Message.Text, "/start ") {
continue
}
token := strings.TrimPrefix(update.Message.Text, "/start ")
t.requests.RLock()
authRequest, ok := t.requests.data[token]
if !ok { // No such token
t.requests.RUnlock()
if t.ErrorMsg != "" {
if err := t.sendText(ctx, update.Message.Chat.ID, t.ErrorMsg); err != nil {
log.Printf("[WARN] failed to notify telegram peer: %v", err)
}
}
continue
}
t.requests.RUnlock()
authRequest.confirmed = true
authRequest.telegramID = strconv.Itoa(update.Message.Chat.ID)
t.requests.Lock()
t.requests.data[token] = authRequest
t.requests.Unlock()
if err := t.sendText(ctx, update.Message.Chat.ID, t.SuccessMsg); err != nil {
log.Printf("[ERROR] failed to notify telegram peer: %v", err)
}
}
}
// sendText sends a plain text message to telegram peer
func (t *Telegram) sendText(ctx context.Context, recipientID int, msg string) error {
url := fmt.Sprintf("sendMessage?chat_id=%d&text=%s", recipientID, neturl.PathEscape(msg))
return t.Request(ctx, url, nil, &struct{}{})
}
// botInfo returns info about configured bot
func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) {
var resp = struct {
Result *TelegramBotInfo `json:"result"`
}{}
err := t.Request(ctx, "getMe", nil, &resp)
if err != nil {
return nil, err
}
if resp.Result == nil {
return nil, errors.New("received empty result")
}
return resp.Result, nil
}
// Request makes a request to the Telegram API and return the result
func (t *Telegram) Request(ctx context.Context, method string, b []byte, data interface{}) error {
return repeater.NewDefault(3, time.Millisecond*250).Do(ctx, func() error {
url := fmt.Sprintf("%s%s/%s", t.apiPrefix, t.Token, method)
var req *http.Request
var err error
if b == nil {
req, err = http.NewRequestWithContext(ctx, "GET", url, http.NoBody)
} else {
req, err = http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
if err != nil {
return errors.Wrap(err, "failed to create request")
}
client := http.Client{Timeout: t.Timeout}
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "failed to send request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return t.parseError(resp.Body, resp.StatusCode)
}
if err = json.NewDecoder(resp.Body).Decode(data); err != nil {
return errors.Wrap(err, "failed to decode json response")
}
return nil
})
}
func (t *Telegram) parseError(r io.Reader, statusCode int) error {
tgErr := struct {
Description string `json:"description"`
}{}
if err := json.NewDecoder(r).Decode(&tgErr); err != nil {
return errors.Errorf("unexpected telegram API status code %d", statusCode)
}
return errors.Errorf("unexpected telegram API status code %d, error: %q", statusCode, tgErr.Description)
}
+436 -53
View File
@@ -4,114 +4,497 @@ import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
func TestTelegram_New(t *testing.T) {
ts := mockTelegramServer()
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "good-token",
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "@remark_test", tb.channelID, "@ added")
assert.Equal(t, tb.Timeout, time.Second*5)
assert.Equal(t, "remark_test", tb.AdminChannelID, "@ added")
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "empty-json",
apiPrefix: ts.URL + "/",
})
assert.EqualError(t, err, "can't retrieve bot info from Telegram API: received empty result")
st := time.Now()
_, err = NewTelegram("bad-resp", "remark_test", 2*time.Second, ts.URL+"/")
assert.EqualError(t, err, "unexpected telegram response {OK:false Result:{FirstName:comments_test ID:707381019 IsBot:false UserName:remark42_test_bot}}")
assert.True(t, time.Since(st) >= 250*5*time.Millisecond)
_, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "non-json-resp",
Timeout: 2 * time.Second,
apiPrefix: ts.URL + "/",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "can't decode response:")
assert.Contains(t, err.Error(), "failed to decode json response:")
assert.True(t, time.Since(st) >= 250*3*time.Millisecond)
_, err = NewTelegram("404", "remark_test", 2*time.Second, ts.URL+"/")
assert.EqualError(t, err, "unexpected telegram status code 404")
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "404",
Timeout: 2 * time.Second,
apiPrefix: ts.URL + "/",
})
assert.EqualError(t, err, "can't retrieve bot info from Telegram API: unexpected telegram API status code 404")
_, err = NewTelegram("no-such-thing", "remark_test", 2*time.Second, "http://127.0.0.1:4321/")
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "no-such-thing",
apiPrefix: "http://127.0.0.1:4321/",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "can't initialize telegram notifications")
assert.Contains(t, err.Error(), "can't retrieve bot info from Telegram API")
assert.Contains(t, err.Error(), "dial tcp 127.0.0.1:4321: connect: connection refused")
_, err = NewTelegram("good-token", "remark_test", 2*time.Second, "")
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "no-such-thing",
apiPrefix: "",
})
assert.Error(t, err, "empty api url not allowed")
_, err = NewTelegram("good-token", "remark_test", 0, ts.URL+"/")
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "good-token",
Timeout: 2 * time.Second,
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err, "0 timeout allowed as default")
tb, err = NewTelegram("good-token", "1234567890", 2*time.Second, ts.URL+"/")
tb, err = NewTelegram(TelegramParams{
AdminChannelID: "1234567890",
Token: "good-token",
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "1234567890", tb.channelID, "no @ prefix")
assert.Equal(t, "1234567890", tb.AdminChannelID, "no @ prefix")
}
func TestTelegram_GetBotUsername(t *testing.T) {
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "good-token",
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "remark42_test_bot", tb.GetBotUsername())
}
func TestTelegram_Send(t *testing.T) {
ts := mockTelegramServer()
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "good-token",
UserNotifications: true,
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
assert.NotNil(t, tb)
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
c := store.Comment{Text: "some text", ParentID: "1", ID: "999", Locator: store.Locator{URL: "http://example.org/"}}
c.User.Name = "from"
cp := store.Comment{Text: "some parent text"}
cp := store.Comment{Text: `<p>some parent text with a <a href="http://example.org">link</a> and special text:<br>& < > &</p>`}
cp.User.Name = "to"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
assert.NoError(t, err)
c.PostTitle = "test title"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
tb, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
assert.Error(t, err, "should failed")
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected telegram status code 404", "send on broken tg")
assert.NoError(t, err)
c.PostTitle = "[test title]"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
assert.NoError(t, err)
assert.Equal(t, "telegram: @remark_test", tb.String())
require.NoError(t, tb.Send(context.TODO(), Request{}), "Empty Comment doesn't send anything")
tb, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "non-json-resp",
UserNotifications: true,
apiPrefix: ts.URL + "/",
})
assert.Nil(t, tb)
assert.Error(t, err, "should fail")
tb = &Telegram{
TelegramParams: TelegramParams{
AdminChannelID: "remark_test",
Token: "non-json-resp",
UserNotifications: true,
apiPrefix: ts.URL + "/",
}}
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected telegram API status code 404", "send on broken tg")
assert.Equal(t, "telegram with admin notifications to remark_test with user notifications enabled", tb.String())
// bad API URL
tb.apiPrefix = "http://non-existent"
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
assert.Error(t, err)
// test buildMessage separately for message text
res, err := buildMessage(Request{Comment: c, parent: cp})
assert.NoError(t, err)
assert.Equal(t, `{"text":"\u003ca href=\"http://example.org/#remark42__comment-999\"\u003efrom\u003c/a\u003e -\u003e \u003ca href=\"http://example.org/#remark42__comment-\"\u003eto\u003c/a\u003e\n\n`+
`some text\n\n`+
`\"\u003ci\u003esome parent text with a \u003ca href=\"http://example.org\"\u003elink\u003c/a\u003e and special text:\u0026amp; \u0026lt; \u0026gt; \u0026amp;\u003c/i\u003e\"\n\n`+
`↦ \u003ca href=\"http://example.org/\"\u003e[test title]\u003c/a\u003e","parse_mode":"HTML"}`,
string(res))
// special case for text with h1-h6 header
ch := store.Comment{Text: "<h1>Hello</h1><h6>World</h6>", ID: "555", Locator: store.Locator{URL: "http://example.org/"}}
ch.User.Name = "from"
res, err = buildMessage(Request{Comment: ch})
assert.NoError(t, err)
assert.Equal(t, `{"text":"\u003ca href=\"http://example.org/#remark42__comment-555\"\u003efrom\u003c/a\u003e\n\n`+
`\u003cb\u003eHello\u003c/b\u003e\u003ci\u003e\u003cb\u003eWorld\u003c/b\u003e\u003c/i\u003e`+
`","parse_mode":"HTML"}`,
string(res))
}
func mockTelegramServer() *httptest.Server {
router := chi.NewRouter()
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `{"ok": true,
func TestTelegram_SendVerification(t *testing.T) {
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "good-token",
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
assert.NotNil(t, tb)
// empty VerificationRequest should return no error and no nothing, as well as any other
assert.NoError(t, tb.SendVerification(context.TODO(), VerificationRequest{}))
}
const getUpdatesResp = `{
"ok": true,
"result": [
{
"update_id": 998,
"message": {
"chat": {
"type": "group"
}
}
},
{
"update_id": 999,
"message": {
"text": "not starting with /start",
"chat": {
"type": "private"
}
}
},
{
"update_id": 1000,
"message": {
"message_id": 4,
"from": {
"id": 313131313,
"is_bot": false,
"first_name": "Joe",
"username": "joe123",
"language_code": "en"
},
"chat": {
"id": 313131313,
"first_name": "Joe",
"username": "joe123",
"type": "private"
},
"date": 1601665548,
"text": "/start token",
"entities": [
{
"offset": 0,
"length": 6,
"type": "bot_command"
}
]
}
}
]
}`
func TestTelegram_GetUpdatesFlow(t *testing.T) {
first := true
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.String(), "sendMessage") {
// respond normally to processUpdates attempt to send message back to user
_, _ = w.Write([]byte("{}"))
return
}
// responses to get updates calls to API
if first {
assert.Equal(t, "", r.URL.Query().Get("offset"))
first = false
} else {
assert.Equal(t, "1001", r.URL.Query().Get("offset"))
}
_, _ = w.Write([]byte(getUpdatesResp))
})
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "xxxsupersecretxxx",
UserNotifications: true,
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
// send request with no offset
upd, err := tb.getUpdates(context.Background())
assert.NoError(t, err)
assert.Len(t, upd.Result, 3)
assert.Equal(t, 1001, tb.updateOffset)
assert.Equal(t, "/start token", upd.Result[len(upd.Result)-1].Message.Text)
tb.AddToken("token", "user", "site", time.Now().Add(time.Minute))
_, _, err = tb.CheckToken("token", "user")
assert.Error(t, err)
tb.processUpdates(context.Background(), upd)
tgID, site, err := tb.CheckToken("token", "user")
assert.NoError(t, err)
assert.Equal(t, "313131313", tgID)
assert.Equal(t, "site", site)
// send request with offset
_, err = tb.getUpdates(context.Background())
assert.NoError(t, err)
}
func TestTelegram_ProcessUpdateFlow(t *testing.T) {
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
// respond normally to processUpdates attempt to send message back to user
_, _ = w.Write([]byte("{}"))
})
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "xxxsupersecretxxx",
UserNotifications: true,
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
tb.AddToken("token", "user", "site", time.Now().Add(time.Minute))
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
assert.Len(t, tb.requests.data, 2)
_, _, err = tb.CheckToken("token", "user")
assert.Error(t, err)
assert.NoError(t, tb.ProcessUpdate(context.Background(), getUpdatesResp))
assert.Len(t, tb.requests.data, 1, "expired token was cleaned up")
tgID, site, err := tb.CheckToken("token", "user")
assert.NoError(t, err)
assert.Len(t, tb.requests.data, 0, "token is deleted after successful check")
assert.Equal(t, "313131313", tgID)
assert.Equal(t, "site", site)
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
assert.Len(t, tb.requests.data, 1)
assert.EqualError(t, tb.ProcessUpdate(context.Background(), ""), "failed to decode provided telegram update: unexpected end of JSON input")
assert.Len(t, tb.requests.data, 0, "expired token should be cleaned up despite the error")
}
const sendMessageResp = `{
"ok": true,
"result": {
"message_id": 100,
"from": {
"id": 666666666,
"is_bot": true,
"first_name": "Test auth bot",
"username": "TestAuthBot"
},
"chat": {
"id": 313131313,
"first_name": "Joe",
"username": "joe123",
"type": "private"
},
"date": 1602430546,
"text": "123"
}
}`
func TestTelegram_SendText(t *testing.T) {
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "123", r.URL.Query().Get("chat_id"))
assert.Equal(t, "hello there", r.URL.Query().Get("text"))
_, _ = w.Write([]byte(sendMessageResp))
})
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "xxxsupersecretxxx",
UserNotifications: true,
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
err = tb.sendText(context.Background(), 123, "hello there")
assert.NoError(t, err)
}
const errorResp = `{"ok":false,"error_code":400,"description":"Very bad request"}`
func TestTelegram_Error(t *testing.T) {
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(errorResp))
})
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "xxxsupersecretxxx",
UserNotifications: true,
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
_, err = tb.getUpdates(context.Background())
assert.EqualError(t, err, "failed to fetch updates: unexpected telegram API status code 400, error: \"Very bad request\"")
}
func TestTelegram_TokenVerification(t *testing.T) {
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.String(), "sendMessage") {
// respond normally to processUpdates attempt to send message back to user
_, _ = w.Write([]byte("{}"))
return
}
// responses to get updates calls to API
_, _ = w.Write([]byte(getUpdatesResp))
})
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "good-token",
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
assert.NotNil(t, tb)
tb.AddToken("token", "user", "site", time.Now().Add(time.Minute))
assert.Len(t, tb.requests.data, 1)
// wrong token
tgID, site, err := tb.CheckToken("unknown token", "user")
assert.Empty(t, tgID)
assert.Empty(t, site)
assert.EqualError(t, err, "request is not found")
// right token and user, not verified yet
tgID, site, err = tb.CheckToken("token", "user")
assert.Empty(t, tgID)
assert.Empty(t, site)
assert.EqualError(t, err, "request is not verified yet")
// confirm request
authRequest, ok := tb.requests.data["token"]
assert.True(t, ok)
authRequest.confirmed = true
authRequest.telegramID = "telegramID"
tb.requests.data["token"] = authRequest
// wrong user
tgID, site, err = tb.CheckToken("token", "wrong user")
assert.Empty(t, tgID)
assert.Empty(t, site)
assert.EqualError(t, err, "user does not match original requester")
// successful check
tgID, site, err = tb.CheckToken("token", "user")
assert.NoError(t, err)
assert.Equal(t, "telegramID", tgID)
assert.Equal(t, "site", site)
// expired token
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
tgID, site, err = tb.CheckToken("expired token", "user")
assert.Empty(t, tgID)
assert.Empty(t, site)
assert.EqualError(t, err, "request expired")
assert.Len(t, tb.requests.data, 0)
// expired token, cleaned up by the cleanup
tb.apiPollInterval = time.Millisecond * 15
tb.expiredCleanupInterval = time.Millisecond * 10
ctx, cancel := context.WithCancel(context.Background())
go tb.Run(ctx)
assert.Eventually(t, func() bool {
return tb.ProcessUpdate(ctx, "").Error() == "Run goroutine should not be used with ProcessUpdate"
}, time.Millisecond*100, time.Millisecond*10, "ProcessUpdate should not work same time as Run")
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
tb.requests.RLock()
assert.Len(t, tb.requests.data, 1)
tb.requests.RUnlock()
time.Sleep(tb.expiredCleanupInterval * 2)
tb.requests.RLock()
assert.Len(t, tb.requests.data, 0)
tb.requests.RUnlock()
cancel()
// give enough time for Run() to finish
time.Sleep(tb.expiredCleanupInterval)
}
const getMeResp = `{"ok": true,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": true,
"username": "remark42_test_bot"
}}`
_, _ = w.Write([]byte(s))
func mockTelegramServer(h http.HandlerFunc) *httptest.Server {
if h != nil {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.String(), "getMe") {
_, _ = w.Write([]byte(getMeResp))
return
}
h(w, r)
}))
}
router := chi.NewRouter()
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(getMeResp))
})
router.Get("/bad-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `{"ok": false,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": false,
"username": "remark42_test_bot"
}}`
_, _ = w.Write([]byte(s))
router.Get("/empty-json/getMe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{}`))
})
router.Get("/non-json-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `"ok": false,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": false,
"username": "remark42_test_bot"
`
_, _ = w.Write([]byte(s))
_, _ = w.Write([]byte(`not-a-json`))
})
router.Get("/404/getMe", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
+1
View File
@@ -0,0 +1 @@
{{
+20
View File
@@ -0,0 +1,20 @@
{{- if .ForAdmin}}
New comment from {{.UserName}} on your site {{if .PostTitle}} to «{{.PostTitle}}»{{ end }}
{{- else }}
New reply from {{.UserName}} on your comment{{if .PostTitle}} to «{{.PostTitle}}»{{ end }}
{{- end }}
{{- if .ParentCommentText}}
{{.ParentUserPicture}}
{{.ParentUserName}}
{{.ParentCommentDate.Format "02.01.2006 at 15:04"}}
Parent comment link: {{.ParentCommentLink}}
{{.ParentCommentText}}
{{- end }}
User: {{.UserName}}
{{.CommentDate.Format "02.01.2006 at 15:04"}}
Comment: {{.CommentText}}
{{.Email}} {{if not .ForAdmin}} for {{.ParentUserName}}{{ end }}
{{- if .UnsubscribeLink}}
Unsubscribe link: {{.UnsubscribeLink}}
{{- end }}
+7
View File
@@ -0,0 +1,7 @@
Confirmation for {{.User}} on site {{.Site}}
{{- if .SubscribeURL}}
Subscribe url: {{.SubscribeURL}}{{.Token}}
{{- end }}
Token:{{.Token}}
Sent to {{.Email}}
+113
View File
@@ -0,0 +1,113 @@
package notify
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"text/template"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
const (
webhookDefaultTemplate = `{"text": "{{.Text}}"}`
)
// WebhookClient defines an interface of client for webhook
type WebhookClient interface {
Do(*http.Request) (*http.Response, error)
}
// WebhookParams contain settings for webhook notifications
type WebhookParams struct {
WebhookURL string
Template string
Headers []string
}
// Webhook implements notify.Destination for Webhook notifications
type Webhook struct {
WebhookParams
webhookClient WebhookClient
webhookTemplate *template.Template
}
// NewWebhook makes Webhook
func NewWebhook(client WebhookClient, params WebhookParams) (*Webhook, error) {
res := &Webhook{WebhookParams: params}
if res.WebhookURL == "" {
return nil, errors.New("webhook URL is required for webhook notifications")
}
if res.Template == "" {
res.Template = webhookDefaultTemplate
}
payloadTmpl, err := template.New("webhook").Parse(res.Template)
if err != nil {
return nil, errors.Wrap(err, "unable to parse webhook template")
}
res.webhookClient = client
res.webhookTemplate = payloadTmpl
log.Printf("[DEBUG] create new webhook notifier for %s", res.WebhookURL)
return res, nil
}
// Send sends Webhook notification
func (t *Webhook) Send(ctx context.Context, req Request) error {
var payload bytes.Buffer
err := t.webhookTemplate.Execute(&payload, req.Comment)
if err != nil {
return errors.Wrap(err, "unable to compile webhook template")
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", t.WebhookURL, &payload)
if err != nil {
return errors.Wrap(err, "unable to create webhook request")
}
for _, h := range t.Headers {
elems := strings.Split(h, ":")
if len(elems) != 2 {
continue
}
httpReq.Header.Set(strings.TrimSpace(elems[0]), strings.TrimSpace(elems[1]))
}
resp, err := t.webhookClient.Do(httpReq)
if err != nil {
return errors.Wrap(err, "webhook request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errMsg := fmt.Sprintf("webhook request failed with non-OK status code: %d", resp.StatusCode)
respBody, e := io.ReadAll(resp.Body)
if e != nil {
return fmt.Errorf(errMsg)
}
return fmt.Errorf("%s, body: %s", errMsg, respBody)
}
log.Printf("[DEBUG] send webhook notification, comment id %s", req.Comment.ID)
return nil
}
// SendVerification is not implemented for Webhook
func (t *Webhook) SendVerification(_ context.Context, _ VerificationRequest) error {
return nil
}
// String describes the webhook instance
func (t *Webhook) String() string {
return fmt.Sprintf("webhook notification to %s", t.WebhookURL)
}
+160
View File
@@ -0,0 +1,160 @@
package notify
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
)
type funcWebhookClient func(*http.Request) (*http.Response, error)
func (c funcWebhookClient) Do(r *http.Request) (*http.Response, error) {
return c(r)
}
var okWebhookClient = funcWebhookClient(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("ok")),
}, nil
})
type errReader struct {
}
func (errReader) Read(p []byte) (n int, err error) {
return 0, errors.New("test error")
}
func TestWebhook_NewWebhook(t *testing.T) {
wh, err := NewWebhook(okWebhookClient, WebhookParams{
WebhookURL: "https://example.org/webhook",
Headers: []string{"Authorization:Basic AXVubzpwQDU1dzByYM=="},
})
assert.NoError(t, err)
assert.NotNil(t, wh)
assert.Equal(t, "https://example.org/webhook", wh.WebhookURL)
assert.Equal(t, []string{"Authorization:Basic AXVubzpwQDU1dzByYM=="}, wh.Headers)
assert.Equal(t, `{"text": "{{.Text}}"}`, wh.Template)
wh, err = NewWebhook(okWebhookClient, WebhookParams{
WebhookURL: "https://example.org/webhook",
Headers: []string{"Authorization:Basic AXVubzpwQDU1dzByYM=="},
Template: "{{.Text}}",
})
assert.NoError(t, err)
assert.NotNil(t, wh)
assert.Equal(t, "{{.Text}}", wh.Template)
wh, err = NewWebhook(okWebhookClient, WebhookParams{})
assert.Nil(t, wh)
assert.Error(t, err)
assert.Equal(t, "webhook URL is required for webhook notifications", err.Error())
wh, err = NewWebhook(okWebhookClient, WebhookParams{WebhookURL: "https://example.org/webhook", Template: "{{.Text"})
assert.Nil(t, wh)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unable to parse webhook template")
}
func TestWebhook_Send(t *testing.T) {
wh, err := NewWebhook(funcWebhookClient(func(r *http.Request) (*http.Response, error) {
assert.Len(t, r.Header, 1)
assert.Equal(t, r.Header.Get("Content-Type"), "application/json,text/plain")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("")),
}, nil
}), WebhookParams{
WebhookURL: "https://example.org/webhook",
Headers: []string{"Content-Type:application/json,text/plain", ""},
})
assert.NoError(t, err)
assert.NotNil(t, wh)
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
c.User.Name = "from"
err = wh.Send(context.TODO(), Request{Comment: c})
assert.NoError(t, err)
wh, err = NewWebhook(okWebhookClient, WebhookParams{
WebhookURL: "https://example.org/webhook",
Template: "{{.InvalidProperty}}",
})
assert.NoError(t, err)
err = wh.Send(context.TODO(), Request{Comment: c})
require.Error(t, err)
assert.Contains(t, err.Error(), "webhook template")
wh, err = NewWebhook(okWebhookClient, WebhookParams{WebhookURL: "https://example.org/webhook"})
assert.NoError(t, err)
err = wh.Send(nil, Request{Comment: c}) // nolint
require.Error(t, err)
assert.Contains(t, err.Error(), "unable to create webhook request")
wh, err = NewWebhook(funcWebhookClient(func(*http.Request) (*http.Response, error) {
return nil, errors.New("request failed")
}), WebhookParams{WebhookURL: "https://not-existing-url.net"})
assert.NoError(t, err)
err = wh.Send(context.TODO(), Request{Comment: c})
require.Error(t, err)
assert.Contains(t, err.Error(), "webhook request failed")
wh, err = NewWebhook(funcWebhookClient(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
}, nil
}), WebhookParams{
WebhookURL: "http:/example.org/invalid-url",
})
assert.NoError(t, err)
err = wh.Send(context.TODO(), Request{Comment: c})
require.Error(t, err)
assert.Contains(t, err.Error(), "non-OK status code: 404, body: not found")
wh, err = NewWebhook(funcWebhookClient(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(errReader{}),
}, nil
}), WebhookParams{
WebhookURL: "http:/example.org/invalid-url",
})
assert.NoError(t, err)
err = wh.Send(context.TODO(), Request{Comment: c})
require.Error(t, err)
assert.Contains(t, err.Error(), "non-OK status code: 404")
assert.NotContains(t, err.Error(), "body")
}
func TestWebhook_SendVerification(t *testing.T) {
wh, err := NewWebhook(okWebhookClient, WebhookParams{WebhookURL: "https://example.org/webhook"})
assert.NoError(t, err)
assert.NotNil(t, wh)
err = wh.SendVerification(context.TODO(), VerificationRequest{})
assert.NoError(t, err)
}
func TestWebhook_String(t *testing.T) {
wh, err := NewWebhook(okWebhookClient, WebhookParams{WebhookURL: "https://example.org/webhook"})
assert.NoError(t, err)
assert.NotNil(t, wh)
str := wh.String()
assert.Equal(t, "webhook notification to https://example.org/webhook", str)
}
+72
View File
@@ -0,0 +1,72 @@
package providers
// Both Telegram auth and notifications need to receive messages received by Telegram bot in the loop,
// and below is the implementation of such loop which dispatched received events to both receivers,
// so that they could work at the same time.
import (
"context"
"encoding/json"
"fmt"
"time"
log "github.com/go-pkgz/lgr"
"github.com/umputun/remark42/backend/app/notify"
)
type tgRequester interface {
Request(ctx context.Context, method string, b []byte, data interface{}) error
}
// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers
type TGUpdatesReceiver interface {
fmt.Stringer
ProcessUpdate(ctx context.Context, textUpdate string) error
}
// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers
// Blocks caller
func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) {
// Identifier of the first update to be requested.
// Should be equal to LastSeenUpdateID + 1
// See https://core.telegram.org/bots/api#getupdates
var updateOffset int
processUpdatedTicker := time.NewTicker(period)
for {
select {
case <-ctx.Done():
processUpdatedTicker.Stop()
return
case <-processUpdatedTicker.C:
url := `getUpdates?allowed_updates=["message"]`
if updateOffset != 0 {
url += fmt.Sprintf("&offset=%d", updateOffset)
}
var update notify.TelegramUpdate
err := requester.Request(ctx, url, nil, &update)
if err != nil {
log.Printf("[WARN] failed to fetch updates: %v", err)
continue
}
for _, u := range update.Result {
if u.UpdateID >= updateOffset {
updateOffset = u.UpdateID + 1
}
}
if raw, err := json.Marshal(update); err == nil {
for _, r := range receivers {
e := r.ProcessUpdate(ctx, string(raw))
if e != nil {
log.Printf("[ERROR] failure from destination %s on processing telegram update %v", r, e)
}
}
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package providers
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark42/backend/app/notify"
)
func TestDispatchTelegramUpdates(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
poolPeriod := time.Millisecond * 100
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
time.Sleep(poolPeriod * 3)
cancel()
time.Sleep(poolPeriod)
}
const getUpdatesResp = `{
"ok": true,
"result": [
{
"update_id": 998,
"message": {
"chat": {
"type": "group"
}
}
}
]
}`
type mockTGRequester struct {
hit int
t *testing.T
}
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data interface{}) error {
if m.hit < 2 {
m.hit++
assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data))
return nil
}
return errors.New("test error")
}
type mockTGUpdatesReceiver struct {
t *testing.T
hit int
}
func (m *mockTGUpdatesReceiver) String() string {
return "mock updater"
}
func (m *mockTGUpdatesReceiver) ProcessUpdate(_ context.Context, textUpdate string) error {
var result notify.TelegramUpdate
err := json.Unmarshal([]byte(textUpdate), &result)
assert.NoError(m.t, err)
if m.hit < 2 {
assert.NotNil(m.t, result.Result)
m.hit++
return nil
}
assert.Nil(m.t, result.Result)
return errors.New("test error")
}
+6 -6
View File
@@ -6,16 +6,16 @@ import (
"path"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/go-pkgz/auth"
cache "github.com/go-pkgz/lcw"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
// admin provides router for all requests available for admin users only
@@ -111,9 +111,9 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
return
}
if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.UserEmail); err != nil {
if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.AllUserDetails); err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code)
return
}
+61 -37
View File
@@ -5,7 +5,7 @@ import (
"compress/gzip"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -13,16 +13,15 @@ import (
"testing"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw"
R "github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestAdmin_Delete(t *testing.T) {
@@ -48,9 +47,9 @@ func TestAdmin_Delete(t *testing.T) {
// check multi count
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
bb, err := ioutil.ReadAll(resp.Body)
bb, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
j := []store.PostInfo{}
err = json.Unmarshal(bb, &j)
@@ -60,12 +59,12 @@ func TestAdmin_Delete(t *testing.T) {
// delete a comment
req, err := http.NewRequest(http.MethodDelete,
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), nil)
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
require.NoError(t, err)
defer resp.Body.Close()
requireAdminOnly(t, req)
resp, err = sendReq(t, req, adminUmputunToken)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
body, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
@@ -98,7 +97,8 @@ func TestAdmin_Delete(t *testing.T) {
resp, err = post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bb, err = ioutil.ReadAll(resp.Body)
bb, err = io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
j = []store.PostInfo{}
err = json.Unmarshal(bb, &j)
@@ -136,11 +136,12 @@ func TestAdmin_Title(t *testing.T) {
addComment(t, c2, ts)
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), nil)
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL))
@@ -170,11 +171,12 @@ func TestAdmin_DeleteUser(t *testing.T) {
_, err = srv.DataService.Create(c3)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), nil)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
assert.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
// all 3 comments here, but for id2 they deleted
@@ -218,12 +220,13 @@ func TestAdmin_Pin(t *testing.T) {
pin := func(val int) int {
client := http.Client{}
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/pin/%s?site=remark42&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), nil)
fmt.Sprintf("%s/api/v1/admin/pin/%s?site=remark42&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
return resp.StatusCode
}
@@ -268,12 +271,12 @@ func TestAdmin_Block(t *testing.T) {
if ttl != "" {
url = url + "&ttl=" + ttl
}
req, err := http.NewRequest(http.MethodPut, url, nil)
req, err := http.NewRequest(http.MethodPut, url, http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode, body
@@ -305,8 +308,9 @@ func TestAdmin_Block(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah"]`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
pi = []store.PostInfo{}
err = json.Unmarshal(body, &pi)
assert.NoError(t, err)
@@ -376,21 +380,23 @@ func TestAdmin_BlockedList(t *testing.T) {
// block user1
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), nil)
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody)
assert.NoError(t, err)
res, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, 200, res.StatusCode)
// block user2
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), nil)
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody)
assert.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, 200, res.StatusCode)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
require.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
@@ -398,6 +404,7 @@ func TestAdmin_BlockedList(t *testing.T) {
users := []store.BlockedUser{}
err = json.NewDecoder(res.Body).Decode(&users)
assert.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, 2, len(users), "two users blocked")
assert.Equal(t, "user1", users[0].ID)
assert.Equal(t, "user1 name", users[0].Name)
@@ -406,7 +413,7 @@ func TestAdmin_BlockedList(t *testing.T) {
t.Logf("%+v", users)
time.Sleep(150 * time.Millisecond)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
require.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
@@ -414,6 +421,7 @@ func TestAdmin_BlockedList(t *testing.T) {
users = []store.BlockedUser{}
err = json.NewDecoder(res.Body).Decode(&users)
assert.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, 1, len(users), "one user left blocked")
}
@@ -437,13 +445,15 @@ func TestAdmin_ReadOnly(t *testing.T) {
// set post to read-only
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
assert.NoError(t, err)
resp, err := sendReq(t, req, "") // non-admin user
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 401, resp.StatusCode)
resp, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
@@ -458,14 +468,16 @@ func TestAdmin_ReadOnly(t *testing.T) {
require.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// reset post's read-only
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
assert.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
@@ -480,6 +492,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
require.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}
@@ -489,11 +502,12 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
// set post to read-only
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.Error(t, err)
@@ -524,11 +538,12 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
// set post to read-only
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
@@ -536,10 +551,11 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
// reset post's read-only
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
assert.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 403, resp.StatusCode)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
@@ -564,11 +580,12 @@ func TestAdmin_Verify(t *testing.T) {
assert.False(t, verified)
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
verified = srv.DataService.IsVerified("remark42", "user1")
assert.True(t, verified)
@@ -583,10 +600,11 @@ func TestAdmin_Verify(t *testing.T) {
assert.True(t, comments.Comments[0].User.Verified)
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody)
assert.NoError(t, err)
resp, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
verified = srv.DataService.IsVerified("remark42", "user1")
assert.False(t, verified)
@@ -632,7 +650,7 @@ func TestAdmin_ExportFile(t *testing.T) {
addComment(t, c1, ts)
addComment(t, c2, ts)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", nil)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody)
require.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
@@ -643,7 +661,8 @@ func TestAdmin_ExportFile(t *testing.T) {
ungzReader, err := gzip.NewReader(resp.Body)
assert.NoError(t, err)
ungzBody, err := ioutil.ReadAll(ungzReader)
assert.NoError(t, resp.Body.Close())
ungzBody, err := io.ReadAll(ungzReader)
assert.NoError(t, err)
assert.Equal(t, 3, strings.Count(string(ungzBody), "\n"))
assert.Equal(t, 2, strings.Count(string(ungzBody), "\"text\""))
@@ -694,19 +713,20 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
},
}
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42", 0700))
require.NoError(t, ioutil.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image", []byte("some image data"), 0600))
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42", 0o700))
require.NoError(t, os.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image", []byte("some image data"), 0o600))
tkn, err := srv.Authenticator.TokenService().Token(claims)
assert.NoError(t, err)
client := http.Client{}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
_, err = srv.DataService.User("remark42", "user1", 0, 0, store.User{})
@@ -734,11 +754,12 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
// try with bad token
client := http.Client{}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), nil)
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode)
// try with bad auth
@@ -761,11 +782,12 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
tkn, err := srv.Authenticator.TokenService().Token(claims)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "bad-password")
resp, err = client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 403, resp.StatusCode)
// try bad user
@@ -773,11 +795,12 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
badClaims.User.ID = "no-such-id"
tkn, err = srv.Authenticator.TokenService().Token(badClaims)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode, resp.Status)
// try without deleteme flag
@@ -785,14 +808,15 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
badClaims2.User.SetBoolAttr("delete_me", false)
tkn, err = srv.Authenticator.TokenService().Token(badClaims2)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
assert.NoError(t, err)
assert.Equal(t, 403, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.True(t, strings.Contains(string(b), "can't use provided token"))
}
+7 -4
View File
@@ -17,8 +17,8 @@ import (
R "github.com/go-pkgz/rest"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/migrator"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark42/backend/app/migrator"
"github.com/umputun/remark42/backend/app/rest"
)
// Migrator rest with import and export controllers
@@ -27,6 +27,7 @@ type Migrator struct {
NativeImporter migrator.Importer
DisqusImporter migrator.Importer
WordPressImporter migrator.Importer
CommentoImporter migrator.Importer
NativeExporter migrator.Exporter
URLMapperMaker migrator.MapperMaker
KeyStore KeyStore
@@ -37,7 +38,7 @@ type Migrator struct {
// KeyStore defines sub-interface for consumers needed just a key
type KeyStore interface {
Key() (key string, err error)
Key(siteID string) (key string, err error)
}
// POST /import?secret=key&site=site-id&provider=disqus|remark|wordpress
@@ -212,7 +213,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
}
// runImport reads from tmpfile and import for given siteID and provider
func (m *Migrator) runImport(siteID string, provider string, tmpfile string) {
func (m *Migrator) runImport(siteID, provider, tmpfile string) {
m.setBusy(siteID, true)
defer func() {
@@ -228,6 +229,8 @@ func (m *Migrator) runImport(siteID string, provider string, tmpfile string) {
importer = m.DisqusImporter
case "wordpress":
importer = m.WordPressImporter
case "commento":
importer = m.CommentoImporter
default:
importer = m.NativeImporter
}
+55 -14
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -17,8 +16,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestMigrator_Import(t *testing.T) {
@@ -43,9 +42,10 @@ func TestMigrator_Import(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
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)
}
@@ -77,9 +77,10 @@ func TestMigrator_ImportForm(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
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)
}
@@ -99,9 +100,38 @@ func TestMigrator_ImportFromWP(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
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)
}
func TestMigrator_ImportFromCommento(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
r := strings.NewReader(`{"version":1,"comments":[{"commentHex":"7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"domain":"example.com","url":"https://example.com/blog/post/1","commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"markdown":"Example content","html":"","parentHex":"root","score":0,"state":"approved","creationDate":"2021-03-17T12:09:47.722181Z",
"direction":0,"deleted":false}],"commenters":[{"commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"email":"somegreatmail@gmail.com","name":"User5276","link":"https://example.com/profile/257","photo":"https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
"provider":"sso:example.com","joinDate":"2021-03-19T19:27:25.954285Z","isModerator":false}]}`)
client := &http.Client{Timeout: 1 * time.Second}
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)
}
@@ -124,6 +154,7 @@ func TestMigrator_ImportRejected(t *testing.T) {
assert.NoError(t, err)
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
@@ -147,6 +178,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
assert.NoError(t, err)
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
client = &http.Client{Timeout: 5 * time.Second}
@@ -156,6 +188,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
assert.NoError(t, err)
resp, err = client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusConflict, resp.StatusCode)
waitForMigrationCompletion(t, ts)
}
@@ -181,15 +214,17 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
require.NoError(t, err)
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
client = &http.Client{Timeout: 5 * time.Second}
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/wait?site=remark42&timeout=5ms", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/wait?site=remark42&timeout=5ms", http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
assert.NoError(t, err)
resp, err = client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
waitForMigrationCompletion(t, ts)
@@ -215,11 +250,12 @@ func TestMigrator_Export(t *testing.T) {
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusAccepted, resp.StatusCode)
waitForMigrationCompletion(t, ts)
// check file mode
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
@@ -229,14 +265,15 @@ func TestMigrator_Export(t *testing.T) {
ungzReader, err := gzip.NewReader(resp.Body)
assert.NoError(t, err)
ungzBody, err := ioutil.ReadAll(ungzReader)
ungzBody, err := io.ReadAll(ungzReader)
assert.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 3, strings.Count(string(ungzBody), "\n"))
assert.Equal(t, 2, strings.Count(string(ungzBody), "\"text\""))
t.Logf("%s", string(ungzBody))
// check stream mode
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=stream&site=remark42", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=stream&site=remark42", http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
@@ -244,16 +281,18 @@ func TestMigrator_Export(t *testing.T) {
require.Equal(t, 200, resp.StatusCode)
require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 3, strings.Count(string(body), "\n"))
assert.Equal(t, 2, strings.Count(string(body), "\"text\""))
t.Logf("%s", string(body))
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42", http.NoBody)
require.NoError(t, err)
resp, err = client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
@@ -305,6 +344,7 @@ func TestMigrator_Remap(t *testing.T) {
rules := "https://remark42.com/* https://www.remark42.com/*"
resp, err := post(t, ts.URL+"/api/v1/admin/remap?site=remark42", rules) // auth as admin
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusAccepted, resp.StatusCode)
waitForMigrationCompletion(t, ts)
@@ -352,18 +392,19 @@ func TestMigrator_RemapReject(t *testing.T) {
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func waitForMigrationCompletion(t *testing.T, ts *httptest.Server) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/wait?site=remark42", nil)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/wait?site=remark42", http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, "{\"site_id\":\"remark42\",\"status\":\"completed\"}\n", string(b))
+140 -84
View File
@@ -6,15 +6,14 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/didip/tollbooth"
"github.com/didip/tollbooth/v6"
"github.com/didip/tollbooth_chi"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/render"
"github.com/go-pkgz/auth"
@@ -25,12 +24,13 @@ import (
"github.com/pkg/errors"
"github.com/rakyll/statik/fs"
"github.com/umputun/remark/backend/app/notify"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/rest/proxy"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/rest/proxy"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/service"
"github.com/umputun/remark42/backend/app/templates"
)
// Rest is a rest access server
@@ -44,23 +44,27 @@ type Rest struct {
CommentFormatter *store.CommentFormatter
Migrator *Migrator
NotifyService *notify.Service
TelegramService telegramService
ImageService *image.Service
Streamer *Streamer
AnonVote bool
WebRoot string
RemarkURL string
AdminEmail string
ReadOnlyAge int
SharedSecret string
ScoreThresholds struct {
Low int
Critical int
}
UpdateLimiter float64
EmailNotifications bool
EmojiEnabled bool
SimpleView bool
UpdateLimiter float64
EmailNotifications bool
TelegramBotUsername string
EmojiEnabled bool
SimpleView bool
ProxyCORS bool
SendJWTHeader bool
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
SubscribersOnly bool
SSLConfig SSLConfig
httpsServer *http.Server
@@ -77,6 +81,7 @@ type Rest struct {
type LoadingCache interface {
Get(key lcw.Key, fn func() ([]byte, error)) (data []byte, err error) // load from cache if found or put to cache and return
Flush(req lcw.FlusherRequest) // evict matched records
Close() error
}
const hardBodyLimit = 1024 * 64 // limit size of body
@@ -89,31 +94,36 @@ type commentsWithInfo struct {
}
// Run the lister and request's router, activate rest server
func (s *Rest) Run(port int) {
func (s *Rest) Run(address string, port int) {
if address == "*" {
address = ""
}
switch s.SSLConfig.SSLMode {
case None:
log.Printf("[INFO] activate http rest server on port %d", port)
log.Printf("[INFO] activate http rest server on %s:%d", address, port)
s.lock.Lock()
s.httpServer = s.makeHTTPServer(port, s.routes())
s.httpServer = s.makeHTTPServer(address, port, s.routes())
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.lock.Unlock()
err := s.httpServer.ListenAndServe()
log.Printf("[WARN] http server terminated, %s", err)
case Static:
log.Printf("[INFO] activate https server in 'static' mode on port %d", s.SSLConfig.Port)
log.Printf("[INFO] activate https server in 'static' mode on %s:%d", address, s.SSLConfig.Port)
s.lock.Lock()
s.httpsServer = s.makeHTTPSServer(s.SSLConfig.Port, s.routes())
s.httpsServer = s.makeHTTPSServer(address, s.SSLConfig.Port, s.routes())
s.httpsServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.httpServer = s.makeHTTPServer(port, s.httpToHTTPSRouter())
s.httpServer = s.makeHTTPServer(address, port, s.httpToHTTPSRouter())
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.lock.Unlock()
go func() {
log.Printf("[INFO] activate http redirect server on port %d", port)
log.Printf("[INFO] activate http redirect server on %s:%d", address, port)
err := s.httpServer.ListenAndServe()
log.Printf("[WARN] http redirect server terminated, %s", err)
}()
@@ -121,14 +131,14 @@ func (s *Rest) Run(port int) {
err := s.httpsServer.ListenAndServeTLS(s.SSLConfig.Cert, s.SSLConfig.Key)
log.Printf("[WARN] https server terminated, %s", err)
case Auto:
log.Printf("[INFO] activate https server in 'auto' mode on port %d", s.SSLConfig.Port)
log.Printf("[INFO] activate https server in 'auto' mode on %s:%d", address, s.SSLConfig.Port)
m := s.makeAutocertManager()
s.lock.Lock()
s.httpsServer = s.makeHTTPSAutocertServer(s.SSLConfig.Port, s.routes(), m)
s.httpsServer = s.makeHTTPSAutocertServer(address, s.SSLConfig.Port, s.routes(), m)
s.httpsServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.httpServer = s.makeHTTPServer(port, s.httpChallengeRouter(m))
s.httpServer = s.makeHTTPServer(address, port, s.httpChallengeRouter(m))
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.lock.Unlock()
@@ -168,9 +178,9 @@ func (s *Rest) Shutdown() {
s.lock.Unlock()
}
func (s *Rest) makeHTTPServer(port int, router http.Handler) *http.Server {
func (s *Rest) makeHTTPServer(address string, port int, router http.Handler) *http.Server {
return &http.Server{
Addr: fmt.Sprintf(":%d", port),
Addr: fmt.Sprintf("%s:%d", address, port),
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
// WriteTimeout: 120 * time.Second, // TODO: such a long timeout needed for blocking export (backup) request
@@ -185,15 +195,24 @@ func (s *Rest) routes() chi.Router {
s.pubRest, s.privRest, s.adminRest, s.rssRest = s.controllerGroups() // assign controllers for groups
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"},
ExposedHeaders: []string{"Authorization"},
AllowCredentials: true,
MaxAge: 300,
})
router.Use(corsMiddleware.Handler)
if s.ProxyCORS {
log.Printf("[WARN] internal CORS disabled")
} else {
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"},
ExposedHeaders: []string{"Authorization"},
AllowCredentials: true,
MaxAge: 300,
})
router.Use(corsMiddleware.Handler)
}
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
logInfoWithBody := logger.New(logger.Log(log.Default()), logger.WithBody, logger.IPfn(ipFn), logger.Prefix("[INFO]")).Handler
@@ -202,13 +221,13 @@ func (s *Rest) routes() chi.Router {
router.Group(func(r chi.Router) {
r.Use(middleware.Timeout(5 * time.Second))
r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(5, nil)), middleware.NoCache)
r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)), middleware.NoCache)
r.Mount("/auth", authHandler)
})
router.Group(func(r chi.Router) {
r.Use(middleware.Timeout(5 * time.Second))
r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)), middleware.NoCache)
r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
r.Mount("/avatar", avatarHandler)
})
@@ -220,7 +239,6 @@ func (s *Rest) routes() chi.Router {
rapi.Group(func(rava chi.Router) {
rava.Use(middleware.Timeout(5 * time.Second))
rava.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
rava.Use(middleware.NoCache)
rava.Mount("/avatar", avatarHandler)
})
@@ -237,7 +255,6 @@ func (s *Rest) routes() chi.Router {
ropen.Get("/count", s.pubRest.countCtrl)
ropen.Post("/counts", s.pubRest.countMultiCtrl)
ropen.Get("/list", s.pubRest.listCtrl)
ropen.Post("/preview", s.pubRest.previewCommentCtrl)
ropen.Get("/info", s.pubRest.infoCtrl)
ropen.Get("/img", s.ImageProxy.Handler)
@@ -249,14 +266,6 @@ func (s *Rest) routes() chi.Router {
})
// open routes, streams, no send timeout
rapi.Route("/stream", func(rstream chi.Router) {
rstream.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
rstream.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody)
rstream.Get("/info", s.pubRest.infoStreamCtrl)
rstream.Get("/last", s.pubRest.lastCommentsStreamCtrl)
})
// open routes, cached
rapi.Group(func(ropen chi.Router) {
ropen.Use(middleware.Timeout(30 * time.Second))
@@ -304,11 +313,11 @@ func (s *Rest) routes() chi.Router {
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(10 * time.Second))
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
rauth.Use(authMiddleware.Auth, matchSiteID)
rauth.Use(middleware.NoCache)
rauth.Use(logger.New(logger.Log(log.Default()), logger.WithBody, logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
rauth.Use(middleware.NoCache, logInfoWithBody)
rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl)
rauth.Post("/preview", s.privRest.previewCommentCtrl)
rauth.Post("/comment", s.privRest.createCommentCtrl)
rauth.Put("/vote/{id}", s.privRest.voteCtrl)
rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl)
@@ -316,6 +325,8 @@ func (s *Rest) routes() chi.Router {
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
rauth.With(rejectAnonUser).Get("/telegram/subscribe", s.privRest.telegramSubscribeCtrl)
rauth.With(rejectAnonUser).Delete("/telegram", s.privRest.deleteTelegramCtrl)
})
// protected routes, anonymous rejected
@@ -353,7 +364,6 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge,
webRoot: s.WebRoot,
streamer: s.Streamer,
}
privGrp := private{
@@ -364,9 +374,10 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
readOnlyAge: s.ReadOnlyAge,
authenticator: s.Authenticator,
notifyService: s.NotifyService,
telegramService: s.TelegramService,
remarkURL: s.RemarkURL,
adminEmail: s.AdminEmail,
anonVote: s.AnonVote,
templates: templates.NewFS(),
}
admGrp := admin{
@@ -402,36 +413,44 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
emails, _ := s.DataService.AdminStore.Email(siteID)
cnf := struct {
Version string `json:"version"`
EditDuration int `json:"edit_duration"`
MaxCommentSize int `json:"max_comment_size"`
Admins []string `json:"admins"`
AdminEmail string `json:"admin_email"`
Auth []string `json:"auth_providers"`
AnonVote bool `json:"anon_vote"`
LowScore int `json:"low_score"`
CriticalScore int `json:"critical_score"`
PositiveScore bool `json:"positive_score"`
ReadOnlyAge int `json:"readonly_age"`
MaxImageSize int `json:"max_image_size"`
EmailNotifications bool `json:"email_notifications"`
EmojiEnabled bool `json:"emoji_enabled"`
SimpleView bool `json:"simple_view"`
Version string `json:"version"`
EditDuration int `json:"edit_duration"`
AdminEdit bool `json:"admin_edit"`
MaxCommentSize int `json:"max_comment_size"`
Admins []string `json:"admins"`
AdminEmail string `json:"admin_email"`
Auth []string `json:"auth_providers"`
AnonVote bool `json:"anon_vote"`
LowScore int `json:"low_score"`
CriticalScore int `json:"critical_score"`
PositiveScore bool `json:"positive_score"`
ReadOnlyAge int `json:"readonly_age"`
MaxImageSize int `json:"max_image_size"`
EmailNotifications bool `json:"email_notifications"`
TelegramBotUsername string `json:"telegram_bot_username"`
EmojiEnabled bool `json:"emoji_enabled"`
SimpleView bool `json:"simple_view"`
SendJWTHeader bool `json:"send_jwt_header"`
SubscribersOnly bool `json:"subscribers_only"`
}{
Version: s.Version,
EditDuration: int(s.DataService.EditDuration.Seconds()),
MaxCommentSize: s.DataService.MaxCommentSize,
Admins: admins,
AdminEmail: emails,
LowScore: s.ScoreThresholds.Low,
CriticalScore: s.ScoreThresholds.Critical,
PositiveScore: s.DataService.PositiveScore,
ReadOnlyAge: s.ReadOnlyAge,
MaxImageSize: s.ImageService.MaxSize,
EmailNotifications: s.EmailNotifications,
EmojiEnabled: s.EmojiEnabled,
AnonVote: s.AnonVote,
SimpleView: s.SimpleView,
Version: s.Version,
EditDuration: int(s.DataService.EditDuration.Seconds()),
AdminEdit: s.DataService.AdminEdits,
MaxCommentSize: s.DataService.MaxCommentSize,
Admins: admins,
AdminEmail: emails,
LowScore: s.ScoreThresholds.Low,
CriticalScore: s.ScoreThresholds.Critical,
PositiveScore: s.DataService.PositiveScore,
ReadOnlyAge: s.ReadOnlyAge,
MaxImageSize: s.ImageService.MaxSize,
EmailNotifications: s.EmailNotifications,
TelegramBotUsername: s.TelegramBotUsername,
EmojiEnabled: s.EmojiEnabled,
AnonVote: s.AnonVote,
SimpleView: s.SimpleView,
SendJWTHeader: s.SendJWTHeader,
SubscribersOnly: s.SubscribersOnly,
}
cnf.Auth = []string{}
@@ -583,7 +602,7 @@ func cacheControl(expiration time.Duration, version string) func(http.Handler) h
fn := func(w http.ResponseWriter, r *http.Request) {
e := `"` + etag(r, version) + `"`
w.Header().Set("Etag", e)
w.Header().Set("Cache-Control", "max-age="+strconv.Itoa(int(expiration.Seconds())))
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, e) {
@@ -597,6 +616,43 @@ func cacheControl(expiration time.Duration, version string) func(http.Handler) h
}
}
// frameAncestors is a middleware setting Content-Security-Policy "frame-ancestors host1 host2 ..."
// prevents loading of comments widgets from any other origins. In case if the list of allowed empty, ignored.
func frameAncestors(hosts []string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if len(hosts) == 0 {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Security-Policy", "frame-ancestors "+strings.Join(hosts, " ")+";")
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// subscribersOnly is a middleware rejecting non-paid_sub users
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if enable {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !user.PaidSub {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
func parseError(err error, defaultCode int) (code int) {
code = defaultCode
+189 -50
View File
@@ -3,8 +3,9 @@ package api
import (
"bytes"
"compress/gzip"
"crypto/rand"
"crypto/sha1" //nolint:gosec //not used for security
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
@@ -12,22 +13,24 @@ import (
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/go-pkgz/auth"
"github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/notify"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/service"
"github.com/umputun/remark42/backend/app/templates"
)
type private struct {
@@ -38,9 +41,17 @@ type private struct {
imageService *image.Service
notifyService *notify.Service
authenticator *auth.Service
telegramService telegramService
remarkURL string
adminEmail string
anonVote bool
templates templates.FileReader
}
// telegramService is a subset of Telegram service used for setting up user telegram notifications
type telegramService interface {
AddToken(token, user, site string, expires time.Time)
CheckToken(token, userID string) (telegram, site string, err error)
GetBotUsername() string
}
type privStore interface {
@@ -51,6 +62,8 @@ type privStore interface {
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
GetUserEmail(siteID string, userID string) (string, error)
SetUserEmail(siteID string, userID string, value string) (string, error)
GetUserTelegram(siteID string, userID string) (string, error)
SetUserTelegram(siteID string, userID string, value string) (string, error)
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
ValidateComment(c *store.Comment) error
IsVerified(siteID string, userID string) bool
@@ -59,20 +72,37 @@ type privStore interface {
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
}
const unsubscribeHTML = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width"/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<div style="text-align: center; font-family: Arial, sans-serif; font-size: 18px;">
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em;">Successfully unsubscribed</p>
</div>
</body>
</html>
`
// POST /preview, body is a comment, returns rendered html
func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
comment := store.Comment{}
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
return
}
comment.User = user
comment.Orig = comment.Text
if err := s.dataService.ValidateComment(&comment); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
return
}
comment = s.commentFormatter.Format(comment)
comment.Sanitize()
// check if images are valid
for _, id := range s.imageService.ExtractPictures(comment.Text) {
err := s.imageService.ResetCleanupTimer(id)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't renew staged picture cleanup timer", rest.ErrImgNotFound)
return
}
}
render.HTML(w, r, comment.Text)
}
// POST /comment - adds comment, resets all immutable fields
func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
@@ -102,6 +132,15 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
}
comment = s.commentFormatter.Format(comment)
// check if images are valid
for _, id := range s.imageService.ExtractPictures(comment.Text) {
_, err := s.imageService.Load(id)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound)
return
}
}
// check if user blocked
if s.dataService.IsBlocked(comment.Locator.SiteID, comment.User.ID) {
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "user blocked", rest.ErrUserBlocked)
@@ -115,7 +154,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
id, err := s.dataService.Create(comment)
if err == service.ErrRestrictedWordsFound {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords)
return
}
if err != nil {
@@ -132,14 +171,9 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
s.cache.Flush(cache.Flusher(comment.Locator.SiteID).
Scopes(comment.Locator.URL, lastCommentsScope, comment.User.ID, comment.Locator.SiteID))
// user notification
if s.notifyService != nil {
s.notifyService.Submit(notify.Request{Comment: finalComment})
}
// admin notification
if s.notifyService != nil && s.adminEmail != "" {
s.notifyService.Submit(notify.Request{Comment: finalComment, Email: s.adminEmail, ForAdmin: true})
}
log.Printf("[DEBUG] created commend %+v", finalComment)
@@ -185,6 +219,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
Orig: edit.Text,
Summary: edit.Summary,
Delete: edit.Delete,
Admin: user.Admin,
}
res, err := s.dataService.EditComment(locator, id, editReq)
@@ -277,12 +312,14 @@ 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.
// GET /email/subscribe?site=siteID&address=someone@example.com
//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) {
user := rest.MustGetUserInfo(r)
address := r.URL.Query().Get("address")
siteID := r.URL.Query().Get("site")
if address == "" {
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "address parameter is required", rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest,
errors.New("missing parameter"), "address parameter is required", rest.ErrInternal)
return
}
existingAddress, err := s.dataService.GetUserEmail(siteID, user.ID)
@@ -290,7 +327,8 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
}
if address == existingAddress {
rest.SendErrorJSON(w, r, http.StatusConflict, errors.New("already verified"), "email address is already verified for this user", rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusConflict,
errors.New("already verified"), "email address is already verified for this user", rest.ErrInternal)
return
}
claims := token.Claims{
@@ -309,20 +347,78 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
return
}
s.notifyService.Submit(
notify.Request{
Email: address,
Verification: notify.VerificationMetadata{
SiteID: siteID,
User: user.Name,
Token: tkn,
},
s.notifyService.SubmitVerification(
notify.VerificationRequest{
SiteID: siteID,
User: user.Name,
Email: address,
Token: tkn,
},
)
render.JSON(w, r, R.JSON{"user": user, "address": address})
}
// telegramSubscribeCtrl generates and verifies telegram notification request
// GET /telegram/subscribe?site=siteID<&tkn=token>
func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
if s.telegramService == nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError,
errors.New("not enabled"), "telegram notifications are not enabled", rest.ErrActionRejected)
return
}
queryToken := r.URL.Query().Get("tkn")
if queryToken == "" {
// GET /telegram/subscribe?site=siteID (No token supplied)
siteID := r.URL.Query().Get("site")
if siteID == "" {
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "site parameter is required", rest.ErrInternal)
return
}
// we don't care as much if we can't retrieve the current value of that field for the user, so ignore the error
if existingAddress, _ := s.dataService.GetUserTelegram(siteID, user.ID); existingAddress != "" {
rest.SendErrorJSON(w, r, http.StatusConflict,
errors.New("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected)
return
}
// Generate and send token
tkn, err := randToken()
if err != nil {
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal)
return
}
expires := time.Now().Add(10 * time.Minute)
s.telegramService.AddToken(tkn, user.ID, siteID, expires)
render.JSON(w, r, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()})
return
}
// GET /telegram/subscribe?tkn=token (verify token)
var address, siteID string
address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't set telegram for user", rest.ErrInternal)
return
}
log.Printf("[DEBUG] set telegram notifications for user %s", user.ID)
val, err := s.dataService.SetUserTelegram(siteID, user.ID, address)
if err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set telegram for user", code)
return
}
render.JSON(w, r, R.JSON{"updated": true, "address": val})
}
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
// PUT /email/confirm?site=siteID&tkn=jwt
func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) {
@@ -345,6 +441,7 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
return
}
// Handshake.ID is user.ID + "::" + address
elems := strings.Split(confClaims.Handshake.ID, "::")
if len(elems) != 2 || elems[0] != user.ID {
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
@@ -379,25 +476,29 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
tkn := r.URL.Query().Get("tkn")
if tkn == "" {
rest.SendErrorHTML(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusBadRequest,
errors.New("missing parameter"), "token parameter is required", rest.ErrInternal, s.templates)
return
}
siteID := r.URL.Query().Get("site")
confClaims, err := s.authenticator.TokenService().Parse(tkn)
if err != nil {
rest.SendErrorHTML(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal, s.templates)
return
}
if s.authenticator.TokenService().IsExpired(confClaims) {
rest.SendErrorHTML(w, r, http.StatusForbidden, errors.New("expired"), "failed to verify confirmation token", rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusForbidden,
errors.New("expired"), "failed to verify confirmation token", rest.ErrInternal, s.templates)
return
}
// Handshake.ID is user.ID + "::" + address
elems := strings.Split(confClaims.Handshake.ID, "::")
if len(elems) != 2 {
rest.SendErrorHTML(w, r, http.StatusBadRequest, errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusBadRequest,
errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal, s.templates)
return
}
userID := elems[0]
@@ -405,14 +506,19 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
existingAddress, err := s.dataService.GetUserEmail(siteID, userID)
if err != nil {
// we don't care as much if we can't retrieve the current value of that field for the user,
// as it's only used to check if we're trying to set to the same value it's already set to
log.Printf("[WARN] can't read email for %s, %v", userID, err)
}
if existingAddress == "" {
rest.SendErrorHTML(w, r, http.StatusConflict, errors.New("user is not subscribed"), "user does not have active email subscription", rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusConflict,
errors.New("user is not subscribed"), "user does not have active email subscription", rest.ErrInternal, s.templates)
return
}
if address != existingAddress {
rest.SendErrorHTML(w, r, http.StatusBadRequest, errors.New("wrong email unsubscription"), "email address in request does not match known for this user", rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusBadRequest,
errors.New("wrong email unsubscription"), "email address in request does not match known for this user",
rest.ErrInternal, s.templates)
return
}
@@ -420,7 +526,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
if err = s.dataService.DeleteUserDetail(siteID, userID, engine.UserEmail); err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
rest.SendErrorHTML(w, r, http.StatusBadRequest, err, "can't delete email for user", code, s.templates)
return
}
// clean User.Email from the token, if user has the token
@@ -431,7 +537,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
if claims.User != nil && claims.User.Email != "" {
claims.User.Email = ""
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
rest.SendErrorHTML(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
rest.SendErrorHTML(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal, s.templates)
return
}
}
@@ -442,8 +548,15 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
panic(err)
}
}
tmpl := template.Must(template.New("unsubscribe").Parse(unsubscribeHTML))
MustRead := func(path string) string {
file, err := s.templates.ReadFile(path)
if err != nil {
panic(err)
}
return string(file)
}
tmplstr := MustRead("unsubscribe.html.tmpl")
tmpl := template.Must(template.New("unsubscribe").Parse(tmplstr))
msg := bytes.Buffer{}
MustExecute(tmpl, &msg, nil)
render.HTML(w, r, msg.String())
@@ -476,6 +589,20 @@ func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, R.JSON{"deleted": true})
}
// DELETE /telegram?site=siteID - removes user's telegram
func (s *private) deleteTelegramCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] remove telegram for user %s", user.ID)
if err := s.dataService.DeleteUserDetail(siteID, user.ID, engine.UserTelegram); err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete telegram for user", code)
return
}
render.JSON(w, r, R.JSON{"deleted": true})
}
// GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments
func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
@@ -599,3 +726,15 @@ func (s *private) isReadOnly(locator store.Locator) bool {
}
return s.dataService.IsReadOnly(locator) // ro manually
}
func randToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", errors.Wrap(err, "can't get random")
}
s := sha1.New() //nolint:gosec // not used for security
if _, err := s.Write(b); err != nil {
return "", errors.Wrap(err, "can't write randoms to sha1")
}
return fmt.Sprintf("%x", s.Sum(nil)), nil
}
+402 -82
View File
@@ -3,11 +3,12 @@ package api
import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"os"
@@ -15,17 +16,17 @@ import (
"testing"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-chi/render"
"github.com/go-pkgz/auth/token"
"github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/notify"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/image"
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
@@ -40,9 +41,10 @@ func TestRest_Create(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/comment",
`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
assert.NoError(t, err)
b, err := ioutil.ReadAll(resp.Body)
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)
@@ -71,6 +73,7 @@ func TestRest_CreateOldPost(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/comment",
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.NoError(t, srv.DataService.DeleteAll("remark42"))
@@ -83,6 +86,7 @@ func TestRest_CreateOldPost(t *testing.T) {
resp, err = post(t, ts.URL+"/api/v1/comment",
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
@@ -95,8 +99,9 @@ func TestRest_CreateTooBig(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/comment", longComment)
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
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)
@@ -107,8 +112,9 @@ func TestRest_CreateTooBig(t *testing.T) {
resp, err = post(t, ts.URL+"/api/v1/comment", veryLongComment)
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err = ioutil.ReadAll(resp.Body)
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)
@@ -120,14 +126,15 @@ func TestRest_CreateWithRestrictedWord(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
badComment := fmt.Sprintf(`{"text": "What the duck is that?", "locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`)
badComment := `{"text": "What the duck is that?", "locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`
resp, err := post(t, ts.URL+"/api/v1/comment", badComment)
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
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)
@@ -144,6 +151,7 @@ func TestRest_CreateRejected(t *testing.T) {
// try to create without auth
resp, err := http.Post(ts.URL+"/api/v1/comment", "", strings.NewReader(body))
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 401, resp.StatusCode)
// try with wrong aud
@@ -153,9 +161,49 @@ func TestRest_CreateRejected(t *testing.T) {
req.Header.Add("X-JWT", devTokenBadAud)
resp, err = client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode, "reject wrong aud")
}
func TestRest_CreateWithWrongImage(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
// create comment
resp, err := post(t, ts.URL+"/api/v1/comment", fmt.Sprintf(`{"text": "![non-existent.jpg](%s/api/v1/picture/dev_user/bad_picture)", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, srv.RemarkURL))
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Contains(t,
string(b),
"{\"code\":20,\"details\":\"can't load picture from the comment\","+
"\"error\":\"can't get image file for dev_user/bad_picture: can't get image stats for dev_user/bad_picture: stat ",
)
assert.Contains(t,
string(b),
"/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n",
)
}
func TestRest_CreateWithLazyImage(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
body := `{"text": "test 123 ![](http://example.com/image.png)", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
// create comment
resp, err := post(t, ts.URL+"/api/v1/comment", body)
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 := store.Comment{}
err = json.Unmarshal(b, &c)
assert.NoError(t, err)
assert.Equal(t, c.Text, "<p>test 123 <img src=\"http://example.com/image.png\" alt=\"\" loading=\"lazy\"/></p>\n")
}
func TestRest_CreateAndGet(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
@@ -165,8 +213,9 @@ func TestRest_CreateAndGet(t *testing.T) {
`{"text": "**test** *123*\n\n http://radio-t.com", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
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)
@@ -209,9 +258,10 @@ func TestRest_Update(t *testing.T) {
req.Header.Add("X-JWT", devToken)
b, err := client.Do(req)
assert.NoError(t, err)
body, err := ioutil.ReadAll(b.Body)
body, err := io.ReadAll(b.Body)
assert.NoError(t, err)
assert.Equal(t, 200, b.StatusCode, string(body))
assert.NoError(t, b.Body.Close())
// comments returned by update
c2 := store.Comment{}
@@ -244,8 +294,9 @@ func TestRest_UpdateDelete(t *testing.T) {
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah1","https://radio-t.com/blah2"]`)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bb, err := ioutil.ReadAll(resp.Body)
bb, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
j := []store.PostInfo{}
err = json.Unmarshal(bb, &j)
assert.NoError(t, err)
@@ -260,9 +311,10 @@ func TestRest_UpdateDelete(t *testing.T) {
req.Header.Add("X-JWT", devToken)
b, err := client.Do(req)
require.NoError(t, err)
body, err := ioutil.ReadAll(b.Body)
body, err := io.ReadAll(b.Body)
require.NoError(t, err)
assert.Equal(t, 200, b.StatusCode, string(body))
assert.NoError(t, b.Body.Close())
// comments returned by update
c2 := store.Comment{}
@@ -285,8 +337,9 @@ func TestRest_UpdateDelete(t *testing.T) {
resp, err = post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah1","https://radio-t.com/blah2"]`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bb, err = ioutil.ReadAll(resp.Body)
bb, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
j = []store.PostInfo{}
err = json.Unmarshal(bb, &j)
require.NoError(t, err)
@@ -310,8 +363,9 @@ func TestRest_UpdateNotOwner(t *testing.T) {
req.Header.Add("X-JWT", devToken)
b, err := client.Do(req)
assert.NoError(t, err)
body, err := ioutil.ReadAll(b.Body)
body, err := io.ReadAll(b.Body)
assert.NoError(t, err)
assert.NoError(t, b.Body.Close())
assert.Equal(t, 403, b.StatusCode, string(body), "update from non-owner")
assert.Equal(t, `{"code":3,"details":"can not edit comments for other users","error":"rejected"}`+"\n", string(body))
@@ -322,6 +376,7 @@ func TestRest_UpdateNotOwner(t *testing.T) {
req.Header.Add("X-JWT", devToken)
b, err = client.Do(req)
assert.NoError(t, err)
assert.NoError(t, b.Body.Close())
assert.Equal(t, 400, b.StatusCode, string(body), "update is not json")
}
@@ -340,6 +395,7 @@ func TestRest_UpdateWrongAud(t *testing.T) {
req.Header.Add("X-JWT", devTokenBadAud)
b, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, b.Body.Close())
assert.Equal(t, http.StatusForbidden, b.StatusCode, "reject update with wrong aut in jwt")
}
@@ -358,8 +414,9 @@ func TestRest_UpdateWithRestrictedWords(t *testing.T) {
req.Header.Add("X-JWT", devToken)
b, err := client.Do(req)
assert.NoError(t, err)
body, err := ioutil.ReadAll(b.Body)
body, err := io.ReadAll(b.Body)
assert.NoError(t, err)
assert.NoError(t, b.Body.Close())
c := R.JSON{}
err = json.Unmarshal(body, &c)
assert.NoError(t, err)
@@ -383,11 +440,12 @@ func TestRest_Vote(t *testing.T) {
vote := func(val int) int {
client := http.Client{}
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), nil)
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)
req.Header.Add("X-JWT", devToken)
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
return resp.StatusCode
}
@@ -400,7 +458,8 @@ func TestRest_Vote(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 1, cr.Score)
assert.Equal(t, 1, cr.Vote)
assert.Equal(t, map[string]bool(nil), cr.Votes)
assert.Equal(t, map[string]bool(nil), cr.Votes, "hidden")
assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs, "hidden")
assert.Equal(t, 200, 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))
@@ -436,10 +495,11 @@ func TestRest_Vote(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, -1, cr.Score)
assert.Equal(t, 0, cr.Vote, "no vote info for not authed user")
assert.Equal(t, map[string]bool(nil), cr.Votes)
assert.Equal(t, map[string]bool(nil), cr.Votes, "hidden")
assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs, "hidden")
req, err := http.NewRequest("GET",
fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), nil)
fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
assert.NoError(t, err)
resp, err := sendReq(t, req, adminUmputunToken)
assert.NoError(t, err)
@@ -447,9 +507,11 @@ func TestRest_Vote(t *testing.T) {
cr = store.Comment{}
err = json.NewDecoder(resp.Body).Decode(&cr)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, -1, cr.Score)
assert.Equal(t, 0, cr.Vote, "no vote info for different user")
assert.Equal(t, map[string]bool(nil), cr.Votes)
assert.Equal(t, map[string]bool(nil), cr.Votes, "hidden")
assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs, "hidden")
}
func TestRest_AnonVote(t *testing.T) {
@@ -467,23 +529,24 @@ func TestRest_AnonVote(t *testing.T) {
vote := func(val int) int {
client := http.Client{}
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), nil)
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)
req.Header.Add("X-JWT", anonToken)
resp, err := client.Do(req)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
return resp.StatusCode
}
getWithAnonAuth := func(url string) (body string, code int) {
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", url, http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", anonToken)
r, err := client.Do(req)
require.NoError(t, err)
defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
b, err := io.ReadAll(r.Body)
assert.NoError(t, err)
return string(b), r.StatusCode
}
@@ -501,12 +564,22 @@ func TestRest_AnonVote(t *testing.T) {
assert.Equal(t, 1, cr.Score)
assert.Equal(t, 1, cr.Vote)
assert.Equal(t, map[string]bool(nil), cr.Votes)
assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs)
}
func TestRest_Email(t *testing.T) {
type MockFS struct{}
func (fs *MockFS) ReadFile(path string) ([]byte, error) {
return []byte(fmt.Sprintf("template %s", path)), nil
}
func TestRest_EmailAndTelegram(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.privRest.templates = &MockFS{}
srv.privRest.telegramService = &mockTelegram{site: "remark42"}
// issue good token
claims := token.Claims{
Handshake: &token.Handshake{ID: "dev::good@example.com"},
@@ -533,8 +606,8 @@ func TestRest_Email(t *testing.T) {
{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: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "send confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "send 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?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: "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 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},
@@ -545,20 +618,31 @@ func TestRest_Email(t *testing.T) {
{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 second time, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusConflict},
{description: "issue delete request without auth", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
{description: "issue delete request without site_id", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusBadRequest},
{description: "delete non-existent user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
{description: "send telegram confirmation, no siteID", url: "/api/v1/telegram/subscribe", method: http.MethodGet, responseCode: http.StatusBadRequest},
{description: "send telegram confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
{description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusConflict},
{description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
{description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
}
client := http.Client{}
for _, x := range testData {
x := x
t.Run(x.description, func(t *testing.T) {
req, err := http.NewRequest(x.method, ts.URL+x.url, nil)
req, err := http.NewRequest(x.method, ts.URL+x.url, http.NoBody)
require.NoError(t, err)
if !x.noAuth {
req.Header.Add("X-JWT", devToken)
}
resp, err := client.Do(req)
require.NoError(t, err)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
// read User.Email from the token in the cookie
for _, c := range resp.Cookies() {
if c.Name == "JWT" {
@@ -578,6 +662,7 @@ func TestRest_EmailNotification(t *testing.T) {
mockDestination := &notify.MockDest{}
srv.privRest.notifyService = notify.NewService(srv.DataService, 1, mockDestination)
defer srv.privRest.notifyService.Close()
client := http.Client{}
@@ -591,70 +676,73 @@ func TestRest_EmailNotification(t *testing.T) {
req.Header.Add("X-JWT", devToken)
resp, err := client.Do(req)
assert.NoError(t, err)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
parentComment := store.Comment{}
require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[0].Email)
assert.Equal(t, "admin@example.org", mockDestination.Get()[1].Email)
require.Equal(t, 1, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[0].Emails)
// create child comment from another user, email notification only to admin expected
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
`{"text": "test 456",
"pid": "%s",
"pid": %q,
"user": {"name": "other_user"},
"locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`, parentComment.ID)))
assert.NoError(t, err)
req.Header.Add("X-JWT", devToken)
req.Header.Add("X-JWT", anonToken)
resp, err = client.Do(req)
assert.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 4, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[2].Email)
assert.Equal(t, "admin@example.org", mockDestination.Get()[3].Email)
require.Equal(t, 2, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[1].Emails)
// send confirmation token for email
req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com", nil)
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", devToken)
resp, err = client.Do(req)
require.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
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, 5, len(mockDestination.Get()))
require.NotEmpty(t, mockDestination.Get()[4].Verification)
verificationToken := mockDestination.Get()[4].Verification.Token
require.Equal(t, 1, len(mockDestination.GetVerify()))
assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email)
verificationToken := mockDestination.GetVerify()[0].Token
// verify email
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), nil)
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err = client.Do(req)
require.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
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))
// get user information to verify the subscription
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/user?site=remark42", nil)
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 = ioutil.ReadAll(resp.Body)
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 user store.User
err = json.Unmarshal(body, &user)
@@ -665,33 +753,35 @@ func TestRest_EmailNotification(t *testing.T) {
// create child comment from another user, email notification expected
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
`{"text": "test 789",
"pid": "%s",
"pid": %q,
"user": {"name": "other_user"},
"locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`, parentComment.ID)))
assert.NoError(t, err)
req.Header.Add("X-JWT", devToken)
req.Header.Add("X-JWT", anonToken)
resp, err = client.Do(req)
assert.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 7, len(mockDestination.Get()))
assert.Equal(t, "good@example.com", mockDestination.Get()[5].Email)
require.Equal(t, 3, len(mockDestination.Get()))
assert.Equal(t, []string{"good@example.com"}, mockDestination.Get()[2].Emails)
// delete user's email
req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/email?site=remark42", nil)
req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/email?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 = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// create child comment from another user, no email notification expected except for admin
// create child comment from another user, no email notification
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
`{"text": "test 321",
"user": {"name": "other_user"},
@@ -701,13 +791,209 @@ func TestRest_EmailNotification(t *testing.T) {
req.Header.Add("X-JWT", devToken)
resp, err = client.Do(req)
assert.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 9, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[7].Email)
require.Equal(t, 4, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[3].Emails)
}
func TestRest_TelegramNotification(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
mockDestination := &notify.MockDest{}
srv.privRest.notifyService = notify.NewService(srv.DataService, 1, mockDestination)
defer srv.privRest.notifyService.Close()
client := http.Client{}
// create new comment from dev user
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
`{"text": "test 123",
"user": {"name": "dev::good@example.com"},
"locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`))
assert.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err := client.Do(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
parentComment := store.Comment{}
require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 1, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[0].Telegrams)
// create child comment from another user, telegram notification only to admin expected
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
`{"text": "test 456",
"pid": %q,
"user": {"name": "other_user"},
"locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`, parentComment.ID)))
assert.NoError(t, err)
req.Header.Add("X-JWT", anonToken)
resp, err = client.Do(req)
assert.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[1].Telegrams)
// subscribe to telegram while the telegram destination is absent
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/telegram/subscribe?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.StatusInternalServerError, resp.StatusCode, string(body))
assert.Equal(t, `{"code":17,"details":"telegram notifications are not enabled","error":"not enabled"}`+"\n", string(body))
mockTlgrm := &mockTelegram{notVerified: true, site: "unknown_site"}
srv.privRest.telegramService = mockTlgrm
// send confirmation token for telegram
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/telegram/subscribe?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 subscribeRequest struct {
Bot string `json:"bot"`
Token string `json:"token"`
}
err = json.Unmarshal(body, &subscribeRequest)
assert.NoError(t, err)
assert.Equal(t, "botUsername", subscribeRequest.Bot)
// verify telegram, unsuccessfully because of not verified
req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), 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.StatusInternalServerError, resp.StatusCode, string(body))
require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"not verified"}`+"\n", string(body))
mockTlgrm.notVerified = false
// verify telegram, unsuccessfully because of unknown site
req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), 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.StatusBadRequest, resp.StatusCode, string(body))
require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"site \"unknown_site\" not found"}`+"\n", string(body))
mockTlgrm.site = "remark42"
// verify telegram, successfully
req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), 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 subscribeResult struct {
Address string `json:"address"`
Updated bool `json:"updated"`
}
err = json.Unmarshal(body, &subscribeResult)
assert.NoError(t, err)
assert.True(t, subscribeResult.Updated)
// get user information to verify 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 user store.User
err = json.Unmarshal(body, &user)
assert.NoError(t, err)
assert.Equal(t, store.User{Name: "developer one", ID: "dev",
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user)
// create child comment from another user, telegram notification expected
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
`{"text": "test 789",
"pid": %q,
"user": {"name": "other_user"},
"locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`, parentComment.ID)))
assert.NoError(t, err)
req.Header.Add("X-JWT", anonToken)
resp, err = client.Do(req)
assert.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 3, len(mockDestination.Get()))
assert.Equal(t, []string{"good_telegram"}, mockDestination.Get()[2].Telegrams)
// delete user's telegram
req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/telegram?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())
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// create child comment from another user, no telegram notification
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
`{"text": "test 321",
"user": {"name": "other_user"},
"locator":{"url": "https://radio-t.com/blah1",
"site": "remark42"}}`))
assert.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err = client.Do(req)
assert.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
// wait for mock notification Submit to kick off
time.Sleep(time.Millisecond * 30)
require.Equal(t, 4, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[3].Telegrams)
}
func TestRest_UserAllData(t *testing.T) {
@@ -717,11 +1003,11 @@ func TestRest_UserAllData(t *testing.T) {
// write 3 comments
user := store.User{ID: "dev", Name: "user name 1"}
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 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",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 20, 0, time.Local)}
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 20, 0, time.Local)}
c3 := store.Comment{User: user, Text: "test test #3", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 25, 0, time.Local)}
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 25, 0, time.Local)}
_, err := srv.DataService.Create(c1)
require.NoError(t, err, "%+v", err)
_, err = srv.DataService.Create(c2)
@@ -730,7 +1016,7 @@ func TestRest_UserAllData(t *testing.T) {
require.NoError(t, err)
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", nil)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err := client.Do(req)
@@ -740,7 +1026,8 @@ func TestRest_UserAllData(t *testing.T) {
ungzReader, err := gzip.NewReader(resp.Body)
assert.NoError(t, err)
ungzBody, err := ioutil.ReadAll(ungzReader)
require.NoError(t, resp.Body.Close())
ungzBody, err := io.ReadAll(ungzReader)
assert.NoError(t, err)
strUungzBody := string(ungzBody)
assert.True(t, strings.HasPrefix(strUungzBody,
@@ -758,10 +1045,11 @@ func TestRest_UserAllData(t *testing.T) {
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, parsed.Info)
assert.Equal(t, 3, len(parsed.Comments))
req, err = http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
require.NoError(t, err)
resp, err = client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, 401, resp.StatusCode)
}
@@ -771,7 +1059,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
user := store.User{ID: "dev", Name: "user name 1"}
c := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 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)}
for i := 0; i < 51; i++ {
c.ID = fmt.Sprintf("id-%03d", i)
@@ -780,7 +1068,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
require.NoError(t, err)
}
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", nil)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err := client.Do(req)
@@ -790,7 +1078,8 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
ungzReader, err := gzip.NewReader(resp.Body)
assert.NoError(t, err)
ungzBody, err := ioutil.ReadAll(ungzReader)
assert.NoError(t, resp.Body.Close())
ungzBody, err := io.ReadAll(ungzReader)
assert.NoError(t, err)
strUngzBody := string(ungzBody)
assert.True(t, strings.HasPrefix(strUngzBody,
@@ -803,13 +1092,14 @@ func TestRest_DeleteMe(t *testing.T) {
defer teardown()
client := http.Client{}
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), nil)
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody)
assert.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err := client.Do(req)
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
m := map[string]string{}
@@ -818,17 +1108,18 @@ func TestRest_DeleteMe(t *testing.T) {
assert.Equal(t, "remark42", m["site"])
assert.Equal(t, "dev", m["user_id"])
token := m["token"]
claims, err := srv.Authenticator.TokenService().Parse(token)
tkn := m["token"]
claims, err := srv.Authenticator.TokenService().Parse(tkn)
assert.NoError(t, err)
assert.Equal(t, "dev", claims.User.ID)
assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+token, 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), nil)
req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody)
assert.NoError(t, err)
resp, err = client.Do(req)
assert.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
}
func TestRest_SavePictureCtrl(t *testing.T) {
@@ -854,8 +1145,9 @@ func TestRest_SavePictureCtrl(t *testing.T) {
resp, err := client.Do(req)
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
m := map[string]string{}
err = json.Unmarshal(body, &m)
@@ -868,31 +1160,36 @@ func TestRest_SavePictureCtrl(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 1462, len(body))
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
id = savePic("picture.gif")
resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id))
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
id = savePic("picture.jpg")
resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id))
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
id = savePic("picture.blah")
resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id))
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/blah/pic.blah", ts.URL))
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode)
}
@@ -910,7 +1207,10 @@ func TestRest_CreateWithPictures(t *testing.T) {
}, image.ServiceParams{
EditDuration: 100 * time.Millisecond,
MaxSize: 2000,
ImageAPI: svc.RemarkURL + "/api/v1/picture/",
ProxyAPI: svc.RemarkURL + "/api/v1/img",
})
defer imageService.Close(context.Background())
svc.privRest.imageService = imageService
svc.ImageService = imageService
@@ -937,8 +1237,9 @@ func TestRest_CreateWithPictures(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
m := map[string]string{}
err = json.Unmarshal(body, &m)
assert.NoError(t, err)
@@ -951,13 +1252,14 @@ func TestRest_CreateWithPictures(t *testing.T) {
ids[i] = uploadPicture(fmt.Sprintf("pic%d.png", i))
}
text := fmt.Sprintf(`text 123 ![](/api/v1/picture/%s) *xxx* ![](/api/v1/picture/%s) ![](/api/v1/picture/%s)`, ids[0], ids[1], ids[2])
body := fmt.Sprintf(`{"text": "%s", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, text)
text := fmt.Sprintf(`text 123 ![](%s/api/v1/picture/%s) *xxx* ![](%s/api/v1/picture/%s) ![](%s/api/v1/picture/%s)`, svc.RemarkURL, ids[0], svc.RemarkURL, ids[1], svc.RemarkURL, ids[2])
body := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, text)
resp, err := post(t, ts.URL+"/api/v1/comment", body)
assert.NoError(t, err)
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode, string(b))
for i := range ids {
@@ -972,3 +1274,21 @@ func TestRest_CreateWithPictures(t *testing.T) {
assert.NoError(t, err, "picture %d moved from staging and available in permanent location", i)
}
}
type mockTelegram struct {
notVerified bool
site string
}
func (m *mockTelegram) AddToken(string, string, string, time.Time) {}
func (m *mockTelegram) GetBotUsername() string {
return "botUsername"
}
func (m *mockTelegram) CheckToken(string, string) (telegram, site string, err error) {
if m.notVerified {
return "", "", errors.New("not verified")
}
return "good_telegram", m.site, nil
}
+29 -127
View File
@@ -5,24 +5,24 @@ import (
"crypto/sha1" // nolint
"encoding/base64"
"io"
"io/ioutil"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
cache "github.com/go-pkgz/lcw"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/service"
)
type public struct {
@@ -31,7 +31,6 @@ type public struct {
readOnlyAge int
commentFormatter *store.CommentFormatter
imageService *image.Service
streamer *Streamer
webRoot string
}
@@ -111,31 +110,6 @@ func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// POST /preview, body is a comment, returns rendered html
func (s *public) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
comment := store.Comment{}
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
return
}
user, err := rest.GetUserInfo(r)
if err != nil { // this not suppose to happen (handled by Auth), just dbl-check
rest.SendErrorJSON(w, r, http.StatusUnauthorized, err, "can't get user info", rest.ErrNoAccess)
return
}
comment.User = user
comment.Orig = comment.Text
if err = s.dataService.ValidateComment(&comment); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
return
}
comment = s.commentFormatter.Format(comment)
comment.Sanitize()
render.HTML(w, r, comment.Text)
}
// GET /info?site=siteID&url=post-url - get info about the post
func (s *public) infoCtrl(w http.ResponseWriter, r *http.Request) {
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
@@ -159,49 +133,6 @@ func (s *public) infoCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// GET /stream/info?site=siteID&url=post-url&since=unix_ts_msec - get info stream about the post
func (s *public) infoStreamCtrl(w http.ResponseWriter, r *http.Request) {
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
log.Printf("[DEBUG] start stream for %+v, timeout=%v, refresh=%v", locator, s.streamer.TimeOut, s.streamer.Refresh)
sinceTs, err := s.parseSince(r)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't translate since parameter", rest.ErrDecode)
return
}
fn := func() steamEventFn {
lastTS := sinceTs
lastCount := 0
return func() (event string, data []byte, upd bool, err error) {
key := cache.NewKey(locator.SiteID).ID(URLKey(r)).Scopes(locator.SiteID, locator.URL)
data, err = s.cache.Get(key, func() ([]byte, error) {
info, e := s.dataService.Info(locator, s.readOnlyAge)
if e != nil {
return nil, e
}
// cache update used as indication of post update. comparing lastTS for no-cache.
// removal won't update lastTS, count check will catch it.
if !lastTS.IsZero() && (info.LastTS != lastTS || info.Count != lastCount) {
upd = true
}
lastTS = info.LastTS
lastCount = info.Count
return encodeJSONWithHTML(info)
})
if err != nil {
return "info", data, false, err
}
return "info", data, upd, nil
}
}
if e := s.streamer.Activate(r.Context(), fn, w); e != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, e, "can't stream", rest.ErrInternal)
}
}
// GET /last/{limit}?site=siteID&since=unix_ts_msec - last comments for the siteID, across all posts, sorted by time, optionally
// limited with "since" param
func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
@@ -240,45 +171,6 @@ func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// GET /stream/last?site=siteID&since=unix_ts_ms - stream of last comments last comments for the siteID, across all posts
func (s *public) lastCommentsStreamCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] get last comments stream for %s", siteID)
sinceTs, err := s.parseSince(r)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't translate since parameter", rest.ErrDecode)
return
}
if sinceTs.IsZero() {
sinceTs = time.Now()
}
fn := func() steamEventFn {
sinceTime := sinceTs
return func() (event string, data []byte, upd bool, err error) {
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(lastCommentsScope)
data, err = s.cache.Get(key, func() ([]byte, error) {
comments, e := s.dataService.Last(siteID, 1, sinceTime, rest.GetUserOrEmpty(r))
if e != nil {
return nil, e
}
sinceTime = time.Now()
if len(comments) > 0 {
sinceTime = comments[0].Timestamp
upd = true
}
return encodeJSONWithHTML(comments)
})
return "last", data, upd, err
}
}
if e := s.streamer.Activate(r.Context(), fn, w); e != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, e, "can't stream", rest.ErrInternal)
}
}
// GET /id/{id}?site=siteID&url=post-url - gets a comment by id
func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
@@ -300,17 +192,22 @@ func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// GET /comments?site=siteID&user=id - returns comments for given userID
// GET /comments?site=siteID&user=id&limit=123&skip=10 - returns comments for given userID
func (s *public) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user")
siteID := r.URL.Query().Get("site")
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil {
limit = 0
getNumWithDef := func(key string) int {
res, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
res = 0
}
return res
}
limit, skip := getNumWithDef("limit"), getNumWithDef("skip")
resp := struct {
Comments []store.Comment `json:"comments,omitempty"`
Count int `json:"count,omitempty"`
@@ -320,7 +217,7 @@ func (s *public) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) {
key := cache.NewKey(siteID).ID(URLKeyWithUser(r)).Scopes(userID, siteID)
data, err := s.cache.Get(key, func() ([]byte, error) {
comments, e := s.dataService.User(siteID, userID, limit, 0, rest.GetUserOrEmpty(r))
comments, e := s.dataService.User(siteID, userID, limit, skip, rest.GetUserOrEmpty(r))
if e != nil {
return nil, e
}
@@ -356,9 +253,10 @@ func (s *public) countCtrl(w http.ResponseWriter, r *http.Request) {
// POST /counts?site=siteID - get number of comments for posts from post body
func (s *public) countMultiCtrl(w http.ResponseWriter, r *http.Request) {
const countBodyLimit int64 = 1024 * 128 // count request can be big for some site because it lists all urls
siteID := r.URL.Query().Get("site")
posts := []string{}
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &posts); err != nil {
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, countBodyLimit), &posts); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get list of posts from request", rest.ErrSiteNotFound)
return
}
@@ -448,7 +346,7 @@ func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
// GET /index.html - respond to /index.html with the content of getstarted.html under /web root
func (s *public) getStartedCtrl(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadFile(path.Join(s.webRoot, "getstarted.html"))
data, err := os.ReadFile(path.Join(s.webRoot, "getstarted.html"))
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
@@ -468,27 +366,31 @@ func (s *public) robotsCtrl(w http.ResponseWriter, r *http.Request) {
func (s *public) applyView(comments []store.Comment, view string) []store.Comment {
if strings.EqualFold(view, "user") {
projection := make([]store.Comment, len(comments))
for i, c := range comments {
projection := make([]store.Comment, 0, len(comments))
for _, c := range comments {
if c.Deleted {
continue
}
p := store.Comment{
ID: c.ID,
User: c.User,
}
projection[i] = p
projection = append(projection, p)
}
return projection
}
return comments
}
func (s *public) parseSince(r *http.Request) (time.Time, error) {
sinceTs := time.Time{}
sinceTS := time.Time{}
if since := r.URL.Query().Get("since"); since != "" {
unixTS, e := strconv.ParseInt(since, 10, 64)
if e != nil {
return time.Time{}, errors.Wrap(e, "can't translate since parameter")
}
sinceTs = time.Unix(unixTS/1000, 1000000*(unixTS%1000)) // since param in msec timestamp
sinceTS = time.Unix(unixTS/1000, 1000000*(unixTS%1000)) // since param in msec timestamp
}
return sinceTs, nil
return sinceTS, nil
}
+118 -368
View File
@@ -1,14 +1,11 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -17,8 +14,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestRest_Ping(t *testing.T) {
@@ -31,19 +28,59 @@ func TestRest_Ping(t *testing.T) {
}
func TestRest_Preview(t *testing.T) {
ts, _, teardown := startupT(t)
ts, srv, teardown := startupT(t)
defer teardown()
resp, err := post(t, ts.URL+"/api/v1/preview", `{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, "<p>test 123</p>\n", string(b))
resp, err = post(t, ts.URL+"/api/v1/preview", "bad")
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode)
resp, err = post(t, ts.URL+"/api/v1/preview", fmt.Sprintf(`{"text": "![non-existent.jpg](%s/api/v1/picture/dev_user/bad_picture)", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, srv.RemarkURL))
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Contains(t,
string(b),
"{\"code\":20,\"details\":\"can't renew staged picture cleanup timer\","+
"\"error\":\"can't get image stats for dev_user/bad_picture: stat",
)
assert.Contains(t,
string(b),
"/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n",
)
}
func TestRest_PreviewWithWrongImage(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
resp, err := post(t, ts.URL+"/api/v1/preview", fmt.Sprintf(`{"text": "![non-existent.jpg](%s/api/v1/picture/dev_user/bad_picture)", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, srv.RemarkURL))
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Contains(t,
string(b),
"{\"code\":20,\"details\":\"can't renew staged picture cleanup timer\","+
"\"error\":\"can't get image stats for dev_user/bad_picture: stat ",
)
assert.Contains(t,
string(b),
"/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n",
)
}
func TestRest_PreviewWithMD(t *testing.T) {
@@ -61,13 +98,13 @@ srv, ts := prep(t)
BKT
`
text = strings.Replace(text, "BKT", "```", -1)
j := fmt.Sprintf(`{"text": "%s", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
j = strings.Replace(j, "\n", "\\n", -1)
resp, err := post(t, ts.URL+"/api/v1/preview", j)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t,
`<h1>h1</h1>
@@ -77,6 +114,7 @@ srv, ts := prep(t)
}
</pre>`,
string(b))
assert.NoError(t, resp.Body.Close())
}
func TestRest_PreviewCode(t *testing.T) {
@@ -88,16 +126,17 @@ func main(aa string) int {return 0}
BKT
`
text = strings.Replace(text, "BKT", "```", -1)
j := fmt.Sprintf(`{"text": "%s", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
j = strings.Replace(j, "\n", "\\n", -1)
resp, err := post(t, ts.URL+"/api/v1/preview", j)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, `<pre class="chroma"><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>`, string(b))
assert.NoError(t, resp.Body.Close())
}
func TestRest_Find(t *testing.T) {
@@ -208,11 +247,12 @@ func TestRest_FindReadOnly(t *testing.T) {
// set post to read-only
client := http.Client{}
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah1&ro=1", ts.URL), nil)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah1&ro=1", ts.URL), http.NoBody)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
_, err = client.Do(req)
resp, err := client.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
tree := service.Tree{}
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
@@ -232,7 +272,7 @@ func TestRest_FindReadOnly(t *testing.T) {
}
func TestRest_FindUserView(t *testing.T) {
ts, _, teardown := startupT(t)
ts, srv, teardown := startupT(t)
defer teardown()
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&view=user")
@@ -265,6 +305,18 @@ func TestRest_FindUserView(t *testing.T) {
assert.Equal(t, "dev", comments.Comments[1].User.ID)
assert.Equal(t, "", comments.Comments[0].Text)
assert.Equal(t, "", comments.Comments[1].Text)
err = srv.DataService.Delete(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}, id1, store.SoftDelete)
assert.NoError(t, err)
srv.Cache.Flush(cache.FlusherRequest{})
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&sort=+time&view=user")
assert.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
require.Equal(t, 1, len(comments.Comments), "1 comment left")
assert.Equal(t, id2, comments.Comments[0].ID)
}
func TestRest_Last(t *testing.T) {
@@ -345,13 +397,15 @@ func TestRest_FindUserComments(t *testing.T) {
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #3", ParentID: "p1",
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
c3 := store.Comment{Text: "test test #3", ParentID: "p1",
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah3"}}
// add 3 comments
addComment(t, c1, ts)
addComment(t, c2, ts)
addComment(t, c2, ts)
addComment(t, c3, ts)
// add one deleted
id := addComment(t, c2, ts)
@@ -361,22 +415,42 @@ func TestRest_FindUserComments(t *testing.T) {
_, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=blah")
assert.Equal(t, 400, code, "noting for user blah")
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev")
assert.Equal(t, 200, code)
{
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev")
assert.Equal(t, 200, code)
resp := struct {
Comments []store.Comment
Count int
}{}
resp := struct {
Comments []store.Comment
Count int
}{}
err = json.Unmarshal([]byte(res), &resp)
assert.NoError(t, err)
require.Equal(t, 3, len(resp.Comments), "should have 3 comments")
assert.Equal(t, 4, resp.Count, "should have 3 count")
err = json.Unmarshal([]byte(res), &resp)
assert.NoError(t, err)
require.Equal(t, 3, len(resp.Comments), "should have 3 comments")
assert.Equal(t, 4, resp.Count, "should have 3+1 count") // TODO: fix as we start to skip deleted
// user comment sorted with -time
assert.True(t, resp.Comments[0].Timestamp.After(resp.Comments[1].Timestamp))
assert.True(t, resp.Comments[1].Timestamp.After(resp.Comments[2].Timestamp))
// user comment sorted with -time
assert.True(t, resp.Comments[0].Timestamp.After(resp.Comments[1].Timestamp))
assert.True(t, resp.Comments[1].Timestamp.After(resp.Comments[2].Timestamp))
}
{
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev&skip=1&limit=2")
assert.Equal(t, 200, code)
resp := struct {
Comments []store.Comment
Count int
}{}
err = json.Unmarshal([]byte(res), &resp)
assert.NoError(t, err)
require.Equal(t, 2, len(resp.Comments), "should have 2 comments due to the limit")
assert.Equal(t, 4, resp.Count, "should have 4 count")
assert.Equal(t, "https://radio-t.com/blah3", resp.Comments[0].Locator.URL)
assert.Equal(t, "https://radio-t.com/blah2", resp.Comments[1].Locator.URL)
}
}
func TestRest_UserInfo(t *testing.T) {
@@ -443,8 +517,9 @@ func TestRest_Counts(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
j := []store.PostInfo{}
err = json.Unmarshal(body, &j)
@@ -455,6 +530,7 @@ func TestRest_Counts(t *testing.T) {
resp, err = post(t, ts.URL+"/api/v1/counts?site=radio-XXX", `{}`)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
}
func TestRest_List(t *testing.T) {
@@ -526,16 +602,17 @@ func TestRest_Config(t *testing.T) {
j := R.JSON{}
err := json.Unmarshal([]byte(body), &j)
assert.NoError(t, err)
assert.Equal(t, 300., j["edit_duration"])
assert.Equal(t, 300.0, j["edit_duration"])
assert.EqualValues(t, []interface{}{"a1", "a2"}, j["admins"])
assert.Equal(t, "admin@remark-42.com", j["admin_email"])
assert.Equal(t, 4000., j["max_comment_size"])
assert.Equal(t, -5., j["low_score"])
assert.Equal(t, -10., j["critical_score"])
assert.Equal(t, 4000.0, j["max_comment_size"])
assert.Equal(t, -5.0, j["low_score"])
assert.Equal(t, -10.0, j["critical_score"])
assert.False(t, j["positive_score"].(bool))
assert.Equal(t, 10., j["readonly_age"])
assert.Equal(t, 10000., j["max_image_size"])
assert.Equal(t, 10.0, j["readonly_age"])
assert.Equal(t, 10000.0, j["max_image_size"])
assert.Equal(t, true, j["emoji_enabled"].(bool))
assert.Equal(t, false, j["admin_edit"].(bool))
}
func TestRest_Info(t *testing.T) {
@@ -546,11 +623,11 @@ func TestRest_Info(t *testing.T) {
user := store.User{ID: "user1", Name: "user name 1"}
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 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",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 20, 0, time.Local)}
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 20, 0, time.Local)}
c3 := store.Comment{User: user, Text: "test test #3", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 25, 0, time.Local)}
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 25, 0, time.Local)}
_, err := srv.DataService.Create(c1)
require.NoError(t, err, "%+v", err)
@@ -566,7 +643,7 @@ func TestRest_Info(t *testing.T) {
err = json.Unmarshal([]byte(body), &info)
assert.NoError(t, err)
exp := store.PostInfo{URL: "https://radio-t.com/blah1", Count: 3,
FirstTS: time.Date(2018, 05, 27, 1, 14, 10, 0, time.Local), LastTS: time.Date(2018, 05, 27, 1, 14, 25, 0, time.Local)}
FirstTS: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local), LastTS: time.Date(2018, 5, 27, 1, 14, 25, 0, time.Local)}
assert.Equal(t, exp, info)
_, code = get(t, ts.URL+"/api/v1/info?site=remark42&url=https://radio-t.com/blah-no")
@@ -575,145 +652,6 @@ func TestRest_Info(t *testing.T) {
assert.Equal(t, 400, code)
}
func TestRest_InfoStream(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 1 * time.Millisecond
srv.pubRest.streamer.TimeOut = 800 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
postComment(t, ts.URL)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 10; i++ {
time.Sleep(10 * time.Millisecond)
postComment(t, ts.URL)
}
}()
body, code := get(t, ts.URL+"/api/v1/stream/info?site=remark42&url=https://radio-t.com/blah1")
assert.Equal(t, 200, code)
<-done
recs := strings.Split(strings.TrimSuffix(body, "\n"), "\n")
require.Equal(t, 10*3, len(recs), "10 records. each 2 lines +1 emty line")
assert.True(t, strings.Contains(recs[0+1], `"count":2`), recs[0])
assert.True(t, strings.Contains(recs[9*3+1], `"count":11`), recs[9])
_, code = get(t, ts.URL+"/api/v1/stream/info?site=remark42&url=https://radio-t.com/blah123")
assert.Equal(t, 500, code)
}
func TestRest_InfoStreamTooMany(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 1 * time.Millisecond
srv.pubRest.streamer.TimeOut = 300 * time.Millisecond
srv.pubRest.streamer.MaxActive = 10
postComment(t, ts.URL)
var errsCount int32
wg := sync.WaitGroup{}
wg.Add(20)
for i := 0; i < 20; i++ {
go func() {
_, code := get(t, ts.URL+"/api/v1/stream/info?site=remark42&url=https://radio-t.com/blah1")
if code == 429 {
atomic.AddInt32(&errsCount, 1)
}
wg.Done()
}()
}
wg.Wait()
assert.Equal(t, int32(10), atomic.LoadInt32(&errsCount), "10 streams rejected")
}
func TestRest_InfoStreamTimeout(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 10 * time.Millisecond
srv.pubRest.streamer.TimeOut = 450 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
postComment(t, ts.URL)
st := time.Now()
_, code := get(t, ts.URL+"/api/v1/stream/info?site=remark42&url=https://radio-t.com/blah1")
assert.Equal(t, 200, code)
assert.True(t, time.Since(st) > time.Millisecond*450 && time.Since(st) < time.Millisecond*500, time.Since(st))
}
func TestRest_InfoStreamCancel(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 10 * time.Millisecond
srv.pubRest.streamer.TimeOut = 1500 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
postComment(t, ts.URL)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 5; i++ {
time.Sleep(300 * time.Millisecond)
postComment(t, ts.URL)
}
}()
client := http.Client{}
req, err := http.NewRequest("GET", ts.URL+"/api/v1/stream/info?site=remark42&url=https://radio-t.com/blah1", nil)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond)
defer cancel()
req = req.WithContext(ctx)
r, err := client.Do(req)
require.NoError(t, err)
defer r.Body.Close()
<-ctx.Done()
<-done
body, err := ioutil.ReadAll(r.Body)
require.EqualError(t, err, "context deadline exceeded")
assert.Equal(t, 200, r.StatusCode)
recs := strings.Count(string(body), "data:")
require.Equal(t, 1, recs, "should have 1 event:\n", string(body))
assert.Contains(t, string(body), `"count":2`)
}
func TestRest_InfoStreamSince(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 10 * time.Millisecond
srv.pubRest.streamer.TimeOut = 900 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
postComment(t, ts.URL)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 10; i++ {
time.Sleep(15 * time.Millisecond)
postComment(t, ts.URL)
}
}()
body, code := get(t, ts.URL+"/api/v1/stream/info?site=remark42&url=https://radio-t.com/blah1&since=12345678")
assert.Equal(t, 200, code)
<-done
recs := strings.Split(strings.TrimSuffix(body, "\n"), "\n")
require.Equal(t, 11*3, len(recs), "include first record, total 11 records. each 2 lines +1 empty line")
}
func TestRest_Robots(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
@@ -725,191 +663,3 @@ func TestRest_Robots(t *testing.T) {
"Allow: /api/v1/list\nAllow: /api/v1/config\nAllow: /api/v1/user\nAllow: /api/v1/img\n"+
"Allow: /api/v1/avatar\nAllow: /api/v1/picture\n", body)
}
func TestRest_LastCommentsStream(t *testing.T) {
t.Skip() // TODO: enable after cache is migrated to https://github.com/dgraph-io/ristretto
ts, srv, teardown := startupT(t)
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 50 * time.Millisecond
srv.pubRest.streamer.TimeOut = 500 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
// stream endpoint currently relies on real cache being present
cacheBackend, err := cache.NewExpirableCache()
require.NoError(t, err)
memCache := cache.NewScache(cacheBackend)
srv.privRest.cache = memCache
srv.pubRest.cache = memCache
postComment(t, ts.URL)
defer teardown()
done := make(chan struct{})
go func() {
defer close(done)
for i := 1; i < 10; i++ {
postComment(t, ts.URL)
time.Sleep(100 * time.Millisecond)
}
t.Log("wrote 10 records")
}()
client := http.Client{}
req, err := http.NewRequest("GET", ts.URL+"/api/v1/stream/last?site=remark42", nil)
require.NoError(t, err)
r, err := client.Do(req)
require.NoError(t, err)
defer r.Body.Close()
<-done
body, err := ioutil.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, 200, r.StatusCode)
assert.Equal(t, "text/event-stream", r.Header.Get("content-type"))
assert.Equal(t, "keep-alive", r.Header.Get("connection"))
recs := strings.Split(strings.TrimSuffix(string(body), "\n"), "\n")
require.Equal(t, 9*3, len(recs), "9 events")
assert.True(t, strings.Contains(recs[1], `test 123`), recs[1])
}
func TestRest_LastCommentsStreamTimeout(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 10 * time.Millisecond
srv.pubRest.streamer.TimeOut = 450 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
postComment(t, ts.URL)
st := time.Now()
_, code := get(t, ts.URL+"/api/v1/stream/last?site=remark42")
assert.Equal(t, 200, code)
assert.True(t, time.Since(st) > time.Millisecond*450 && time.Since(st) < time.Millisecond*500, time.Since(st))
}
func TestRest_LastCommentsStreamCancel(t *testing.T) {
ts, srv, teardown := startupT(t)
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 10 * time.Millisecond
srv.pubRest.streamer.TimeOut = 500 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
// stream endpoint currently relies on real cache being present
cacheBackend, err := cache.NewExpirableCache()
require.NoError(t, err)
memCache := cache.NewScache(cacheBackend)
srv.privRest.cache = memCache
srv.pubRest.cache = memCache
postComment(t, ts.URL)
defer teardown()
done := make(chan struct{})
go func() {
defer close(done)
for i := 1; i < 10; i++ {
time.Sleep(100 * time.Millisecond)
postComment(t, ts.URL)
}
}()
client := http.Client{}
req, err := http.NewRequest("GET", ts.URL+"/api/v1/stream/last?site=remark42", nil)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 290*time.Millisecond)
defer cancel()
req = req.WithContext(ctx)
r, err := client.Do(req)
require.NoError(t, err)
<-done
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
require.EqualError(t, err, "context deadline exceeded")
assert.Equal(t, 200, r.StatusCode)
recs := strings.Split(strings.TrimSuffix(string(body), "\n"), "\n")
assert.True(t, len(recs) < 30, "less 10 events")
}
func TestRest_LastCommentsStreamTooMany(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 1 * time.Millisecond
srv.pubRest.streamer.TimeOut = 300 * time.Millisecond
srv.pubRest.streamer.MaxActive = 10
postComment(t, ts.URL)
var errsCount int32
wg := sync.WaitGroup{}
wg.Add(20)
for i := 0; i < 20; i++ {
go func() {
_, code := get(t, ts.URL+"/api/v1/stream/last?site=remark42")
if code == 429 {
atomic.AddInt32(&errsCount, 1)
}
wg.Done()
}()
}
wg.Wait()
assert.Equal(t, int32(10), atomic.LoadInt32(&errsCount), "10 streams rejected")
_, code := get(t, ts.URL+"/api/v1/stream/last?site=remark42")
assert.Equal(t, 200, code, "all streams closed, good to go again")
}
func TestRest_LastCommentsStreamSince(t *testing.T) {
ts, srv, teardown := startupT(t)
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.streamer.Refresh = 10 * time.Millisecond
srv.pubRest.streamer.TimeOut = 500 * time.Millisecond
srv.pubRest.streamer.MaxActive = 100
// stream endpoint currently relies on real cache being present
cacheBackend, err := cache.NewExpirableCache()
require.NoError(t, err)
memCache := cache.NewScache(cacheBackend)
srv.privRest.cache = memCache
srv.pubRest.cache = memCache
postComment(t, ts.URL)
defer teardown()
done := make(chan struct{})
go func() {
defer close(done)
for i := 1; i < 10; i++ {
time.Sleep(50 * time.Millisecond)
postComment(t, ts.URL)
}
}()
client := http.Client{}
req, err := http.NewRequest("GET", ts.URL+"/api/v1/stream/last?site=remark42&since=123456", nil)
require.NoError(t, err)
r, err := client.Do(req)
require.NoError(t, err)
<-done
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, 200, r.StatusCode)
assert.Equal(t, "text/event-stream", r.Header.Get("content-type"))
recs := strings.Split(strings.TrimSuffix(string(body), "\n"), "\n")
require.Equal(t, 10*3, len(recs), "should be 10 events, including first record:\n", recs)
}
func postComment(t *testing.T, url string) {
resp, err := post(t, url+"/api/v1/comment",
`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
require.NoError(t, err)
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode, string(b))
}
+138 -55
View File
@@ -6,7 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"io"
"math/rand"
"net"
"net/http"
@@ -25,16 +25,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
"go.uber.org/goleak"
"github.com/umputun/remark/backend/app/migrator"
"github.com/umputun/remark/backend/app/notify"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/rest/proxy"
"github.com/umputun/remark/backend/app/store"
adminstore "github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark/backend/app/store/engine"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark/backend/app/store/service"
"github.com/umputun/remark42/backend/app/migrator"
"github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/rest/proxy"
"github.com/umputun/remark42/backend/app/store"
adminstore "github.com/umputun/remark42/backend/app/store/admin"
"github.com/umputun/remark42/backend/app/store/engine"
"github.com/umputun/remark42/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/service"
)
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg`
@@ -51,7 +52,7 @@ func TestRest_FileServer(t *testing.T) {
testHTMLName := "test-remark.html"
testHTMLFile := os.TempDir() + "/" + testHTMLName
err := ioutil.WriteFile(testHTMLFile, []byte("some html"), 0700)
err := os.WriteFile(testHTMLFile, []byte("some html"), 0o700)
assert.NoError(t, err)
body, code := get(t, ts.URL+"/web/"+testHTMLName)
@@ -65,7 +66,7 @@ func TestRest_GetStarted(t *testing.T) {
defer teardown()
getStartedHTML := os.TempDir() + "/getstarted.html"
err := ioutil.WriteFile(getStartedHTML, []byte("some html blah"), 0700)
err := os.WriteFile(getStartedHTML, []byte("some html blah"), 0o700)
assert.NoError(t, err)
body, code := get(t, ts.URL+"/index.html")
@@ -91,7 +92,7 @@ func TestRest_Shutdown(t *testing.T) {
}()
st := time.Now()
srv.Run(0)
srv.Run("127.0.0.1", 0)
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms")
<-done
}
@@ -99,11 +100,11 @@ func TestRest_Shutdown(t *testing.T) {
func TestRest_filterComments(t *testing.T) {
user := store.User{ID: "user1", Name: "user name 1"}
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 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: "radio-t",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 20, 0, time.Local)}
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 20, 0, time.Local)}
c3 := store.Comment{User: user, Text: "test test #3", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 25, 0, time.Local)}
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 25, 0, time.Local)}
r := filterComments([]store.Comment{c1, c2, c3}, func(c store.Comment) bool {
return c.Text == "test test #1" || c.Text == "test test #3"
@@ -131,7 +132,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
port := chooseRandomUnusedPort()
go func() {
srv.Run(port)
srv.Run("", port)
}()
waitForHTTPSServerStart(sslPort)
@@ -158,7 +159,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
@@ -180,7 +181,7 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
port := chooseRandomUnusedPort()
go func() {
// can't check https server locally, just only http server
srv.Run(port)
srv.Run("", port)
}()
waitForHTTPSServerStart(sslPort)
@@ -210,14 +211,17 @@ func TestRest_rejectAnonUser(t *testing.T) {
resp, err := http.Get(ts.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in")
resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected")
resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode, "real user")
}
@@ -235,7 +239,7 @@ func Test_URLKey(t *testing.T) {
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
r, err := http.NewRequest("GET", tt.url, nil)
r, err := http.NewRequest("GET", tt.url, http.NoBody)
require.NoError(t, err)
if tt.user.ID != "" {
r = rest.SetUserInfo(r, tt.user)
@@ -261,7 +265,7 @@ func Test_URLKeyWithUser(t *testing.T) {
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
r, err := http.NewRequest("GET", tt.url, nil)
r, err := http.NewRequest("GET", tt.url, http.NoBody)
require.NoError(t, err)
if tt.user.ID != "" {
r = rest.SetUserInfo(r, tt.user)
@@ -320,30 +324,103 @@ func TestRest_cacheControl(t *testing.T) {
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
t.Logf("%+v", resp.Header)
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds())), resp.Header.Get("Cache-Control"))
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
})
}
}
func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
tmp := os.TempDir()
var testDb string
// pick a file name which is not in use for sure
func TestRest_frameAncestors(t *testing.T) {
tbl := []struct {
hosts []string
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 {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
w := httptest.NewRecorder()
h := frameAncestors(tt.hosts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
t.Logf("%+v", resp.Header)
assert.Equal(t, tt.header, resp.Header.Get("Content-Security-Policy"))
})
}
}
func TestRest_subscribersOnly(t *testing.T) {
paidSubUser := &token.User{}
paidSubUser.SetPaidSub(true)
tbl := []struct {
subsOnly bool
user token.User
setUser bool
status int
}{
{true, token.User{}, false, http.StatusUnauthorized},
{true, token.User{}, true, http.StatusForbidden},
{false, token.User{}, false, http.StatusOK},
{false, token.User{}, true, http.StatusOK},
{true, *paidSubUser, true, http.StatusOK},
}
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
if tt.setUser {
req = token.SetUserInfo(req, tt.user)
}
w := httptest.NewRecorder()
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, tt.status, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
})
}
}
// randomPath pick a file or folder name which is not in use for sure
func randomPath(tempDir, basename, suffix string) (string, error) {
for i := 0; i < 10; i++ {
testDb = fmt.Sprintf("/%s/test-remark-%d.db", tmp, rand.Int31())
_, err := os.Stat(testDb)
fname := fmt.Sprintf("/%s/%s-%d%s", tempDir, basename, rand.Int31(), suffix)
fmt.Printf("fname %q", fname)
_, err := os.Stat(fname)
if err != nil {
break
return fname, nil
}
}
return "", errors.New("cannot create temp file")
}
func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
tmp := os.TempDir()
testDB, err := randomPath(tmp, "test-remark", ".db")
require.NoError(t, err)
_ = os.RemoveAll(tmp + "/ava-remark42")
_ = os.RemoveAll(tmp + "/pics-remark42")
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)
memCache := cache.NewScache(cache.NewNopCache())
@@ -360,6 +437,8 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
RestrictedWordsMatcher: restrictedWordsMatcher,
}
remarkURL := "https://demo.remark42.com"
srv = &Rest{
DataService: dataStore,
Authenticator: auth.NewService(auth.Opts{
@@ -367,16 +446,17 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
SecretReader: token.SecretFunc(func(aud string) (string, error) { return "secret", nil }),
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
}),
Cache: memCache,
WebRoot: tmp,
RemarkURL: "https://demo.remark42.com",
AdminEmail: "admin@example.org",
Cache: memCache,
WebRoot: tmp,
RemarkURL: remarkURL,
ImageService: image.NewService(&image.FileSystem{
Location: tmp + "/pics-remark42",
Partitions: 100,
Staging: tmp + "/pics-remark42/staging",
}, image.ServiceParams{
EditDuration: 100 * time.Millisecond,
ImageAPI: remarkURL + "/api/v1/picture/",
ProxyAPI: remarkURL + "/api/v1/img",
MaxSize: 10000,
}),
ImageProxy: &proxy.Image{},
@@ -385,17 +465,13 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
Migrator: &Migrator{
DisqusImporter: &migrator.Disqus{DataStore: dataStore},
WordPressImporter: &migrator.WordPress{DataStore: dataStore},
CommentoImporter: &migrator.Commento{DataStore: dataStore},
NativeImporter: &migrator.Native{DataStore: dataStore},
NativeExporter: &migrator.Native{DataStore: dataStore},
URLMapperMaker: migrator.NewURLMapper,
Cache: memCache,
KeyStore: astore,
},
Streamer: &Streamer{
Refresh: 100 * time.Millisecond,
TimeOut: 5 * time.Second,
MaxActive: 100,
},
NotifyService: notify.NopService,
EmojiEnabled: true,
}
@@ -406,7 +482,7 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
teardown = func() {
ts.Close()
require.NoError(t, srv.DataService.Close())
_ = os.Remove(testDb)
_ = os.Remove(testDB)
_ = os.RemoveAll(tmp + "/ava-remark42")
_ = os.RemoveAll(tmp + "/pics-remark42")
}
@@ -428,49 +504,49 @@ func fakeAuth(next http.Handler) http.Handler {
return http.HandlerFunc(fn)
}
func get(t *testing.T, url string) (string, int) {
func get(t *testing.T, url string) (response string, statusCode int) {
r, err := http.Get(url)
require.NoError(t, err)
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.NoError(t, r.Body.Close())
return string(body), r.StatusCode
}
func sendReq(_ *testing.T, r *http.Request, token string) (*http.Response, error) {
func sendReq(_ *testing.T, r *http.Request, tkn string) (*http.Response, error) {
client := http.Client{Timeout: 5 * time.Second}
if token != "" {
r.Header.Set("X-JWT", token)
if tkn != "" {
r.Header.Set("X-JWT", tkn)
}
return client.Do(r)
}
func getWithDevAuth(t *testing.T, url string) (body string, code int) {
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", url, http.NoBody)
require.NoError(t, err)
req.Header.Add("X-JWT", devToken)
r, err := client.Do(req)
require.NoError(t, err)
defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
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) (string, int) {
func getWithAdminAuth(t *testing.T, url string) (response string, statusCode int) {
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", url, http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
r, err := client.Do(req)
require.NoError(t, err)
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
require.NoError(t, r.Body.Close())
return string(body), r.StatusCode
}
func post(t *testing.T, url string, body string) (*http.Response, error) {
func post(t *testing.T, url, body string) (*http.Response, error) {
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("POST", url, strings.NewReader(body))
assert.NoError(t, err)
@@ -489,7 +565,8 @@ func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
resp, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
b, err = ioutil.ReadAll(resp.Body)
b, err = io.ReadAll(resp.Body)
require.NoError(t, resp.Body.Close())
require.NoError(t, err)
crResp := R.JSON{}
@@ -502,10 +579,12 @@ func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
func requireAdminOnly(t *testing.T, req *http.Request) {
resp, err := sendReq(t, req, "") // no-auth user
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 401, resp.StatusCode)
resp, err = sendReq(t, req, devToken) // non-admin user
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 403, resp.StatusCode)
}
@@ -531,3 +610,7 @@ func waitForHTTPSServerStart(port int) {
}
}
}
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/gorilla/feeds"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store"
)
type rss struct {
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
func TestServer_RssPost(t *testing.T) {
@@ -279,7 +279,7 @@ func waitOnSecChange() {
}
// clean formatting, i.e. multiple spaces, \t, \n
func cleanRssFormatting(expected, actual string) (string, string) {
func cleanRssFormatting(expected, actual string) (cleanExp, cleanAct string) {
reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`)
expected = strings.Replace(expected, "\n", " ", -1)
+6 -6
View File
@@ -6,8 +6,8 @@ import (
"net/url"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
log "github.com/go-pkgz/lgr"
"golang.org/x/crypto/acme/autocert"
@@ -84,8 +84,8 @@ func (s *Rest) makeAutocertManager() *autocert.Manager {
}
// makeHTTPSAutoCertServer makes https server with autocert mode (LE support)
func (s *Rest) makeHTTPSAutocertServer(port int, router http.Handler, m *autocert.Manager) *http.Server {
server := s.makeHTTPServer(port, router)
func (s *Rest) makeHTTPSAutocertServer(address string, port int, router http.Handler, m *autocert.Manager) *http.Server {
server := s.makeHTTPServer(address, port, router)
cfg := s.makeTLSConfig()
cfg.GetCertificate = m.GetCertificate
server.TLSConfig = cfg
@@ -93,8 +93,8 @@ func (s *Rest) makeHTTPSAutocertServer(port int, router http.Handler, m *autocer
}
// makeHTTPSServer makes https server for static mode
func (s *Rest) makeHTTPSServer(port int, router http.Handler) *http.Server {
server := s.makeHTTPServer(port, router)
func (s *Rest) makeHTTPSServer(address string, port int, router http.Handler) *http.Server {
server := s.makeHTTPServer(address, port, router)
server.TLSConfig = s.makeTLSConfig()
return server
}
+3 -3
View File
@@ -3,7 +3,7 @@ package api
import (
"context"
"crypto/tls"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -68,7 +68,7 @@ func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
assert.Equal(t, "https://localhost:443/blah?param=1", resp.Header.Get("Location"))
// check acme http challenge
req, err := http.NewRequest("GET", ts.URL+"/.well-known/acme-challenge/token123", nil)
req, err := http.NewRequest("GET", ts.URL+"/.well-known/acme-challenge/token123", http.NoBody)
require.NoError(t, err)
req.Host = "localhost" // for passing hostPolicy check
resp, err = client.Do(req)
@@ -83,7 +83,7 @@ func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "token", string(body))
}
-103
View File
@@ -1,103 +0,0 @@
package api
import (
"context"
"fmt"
"io"
"net/http"
"sync/atomic"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
// Streamer creates endless stream of \n separated json records send to remote client
type Streamer struct {
TimeOut time.Duration
Refresh time.Duration
MaxActive int32
activeCount int32
}
type steamEventFn func() (event string, data []byte, upd bool, err error)
type steamEventResp struct {
data []byte
event string
err error
}
// Activate starts blocking function streaming update created by eventFn to ResponseWriter
// canceled on context or inactivity timeout
// note: eventFn is a closure needed to allow state management inside eventFn
func (s *Streamer) Activate(ctx context.Context, eventFn func() steamEventFn, w io.Writer) error {
updCh := s.eventsCh(ctx, eventFn())
count := atomic.AddInt32(&s.activeCount, 1)
defer atomic.AddInt32(&s.activeCount, -1)
if count > s.MaxActive {
return errors.New("too many streams")
}
if ww, ok := w.(http.ResponseWriter); ok {
ww.Header().Set("Content-Type", "text/event-stream")
ww.Header().Set("Connection", "keep-alive")
ww.Header().Set("Cache-Control", "no-cache")
}
for {
select {
case <-ctx.Done(): // request closed by remote client
log.Printf("[DEBUG] stream closed by remote client, %s", ctx.Err())
return nil
case <-time.After(s.TimeOut): // request closed by timeout
log.Printf("[DEBUG] stream closed due to timeout")
return nil
case resp, ok := <-updCh: // new update
if !ok { // closed updCh
return nil
}
if resp.err != nil {
return resp.err
}
// make server-sent event record
// see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
if _, e := fmt.Fprintf(w, "event: %s\ndata: %s\n", resp.event, string(resp.data)); e != nil {
return errors.Wrap(e, "send to stream failed")
}
if fw, okFlush := w.(http.Flusher); okFlush {
fw.Flush()
}
}
}
}
// populate updates to chan, break on context close
func (s *Streamer) eventsCh(ctx context.Context, fn steamEventFn) <-chan steamEventResp {
ch := make(chan steamEventResp)
go func() {
tick := time.NewTicker(s.Refresh)
defer func() {
close(ch)
tick.Stop()
}()
for {
select {
case <-ctx.Done(): // request closed by remote client
return
case <-tick.C:
event, resp, upd, err := fn()
if err != nil {
ch <- steamEventResp{event: event, data: nil, err: errors.Wrap(err, "can't get stream data")}
return
}
if upd {
ch <- steamEventResp{event: event, data: resp, err: nil}
}
}
}
}()
return ch
}
-61
View File
@@ -1,61 +0,0 @@
package api
import (
"bytes"
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestStream_Timeout(t *testing.T) {
s := Streamer{
Refresh: 10 * time.Millisecond,
TimeOut: 100 * time.Millisecond,
MaxActive: 10,
}
eventFn := func() steamEventFn {
n := 0
return func() (event string, data []byte, upd bool, err error) {
n++
if n%2 == 0 || n > 10 {
return "test", nil, false, nil
}
return "test", []byte(fmt.Sprintf("some data %d\n", n)), true, nil
}
}
buf := bytes.Buffer{}
err := s.Activate(context.Background(), eventFn, &buf)
assert.NoError(t, err)
assert.Equal(t, "event: test\ndata: some data 1\n\nevent: test\ndata: some data 3\n\nevent: test\ndata: some data 5\n\nevent: test\ndata: some data 7\n\nevent: test\ndata: some data 9\n\n", buf.String())
}
func TestStream_Cancel(t *testing.T) {
s := Streamer{
Refresh: 10 * time.Millisecond,
TimeOut: 100 * time.Millisecond,
MaxActive: 10,
}
eventFn := func() steamEventFn {
n := 0
return func() (event string, data []byte, upd bool, err error) {
n++
if n%2 == 0 {
return "test", nil, false, nil
}
return "test", []byte(fmt.Sprintf("some data %d\n", n)), true, nil
}
}
buf := bytes.Buffer{}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := s.Activate(ctx, eventFn, &buf)
assert.NoError(t, err)
assert.Equal(t, "event: test\ndata: some data 1\n\nevent: test\ndata: some data 3\n\nevent: test\ndata: some data 5\n\nevent: test\ndata: some data 7\n\nevent: test\ndata: some data 9\n\n", buf.String())
}

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