Compare commits

...
826 Commits
Author SHA1 Message Date
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
Dmitry VerkhoturovandUmputun 71dcb13a71 commit proxy images same way as normally uploaded ones 2020-04-25 12:15:26 -05:00
Dmitry VerkhoturovandUmputun 5579bd1a88 close response.Body in image proxy tests 2020-04-24 14:16:01 -05:00
Dmitry VerkhoturovandUmputun b71e0be095 move cachedImgID and sha1Str from rest.proxy to store.image 2020-04-24 14:16:01 -05:00
UmputunandGitHub 4b26b8e259 Merge pull request #699 from umputun/github_golangci_lint
change golangci-lint format to github-actions for CI
2020-04-23 18:07:03 -05:00
Dmitry Verkhoturov 1a2cceba4a change golangci-lint format to github-actions for CI 2020-04-24 01:02:03 +02:00
Umputun 42509cf0dd update linter to 1.25 2020-04-23 01:13:35 -05:00
UmputunandGitHub d3c77d0c4c Merge pull request #693 from umputun/img_commit_on_start
Image commit on start
2020-04-23 00:52:27 -05:00
Dmitry Verkhoturov 8cb274934e add DataService.ResubmitStagingImages function call on server start 2020-04-23 00:55:13 +02:00
Dmitry Verkhoturov 5ae6dcf652 introduce image.Service commitTTL and cleanupTTL properties
This allows having separate values of TTL for Commit and Cleanup
and moving them apart in time, also clarifying their connection
to EditTime which was previously outside of the package level.
2020-04-23 00:54:30 +02:00
Dmitry Verkhoturov c7f73a6c84 add image.Store.Info() function 2020-04-23 00:52:36 +02:00
UmputunandGitHub cebc40f9ce Merge pull request #697 from umputun/tests_golangci_lint
Enable golangci-lint for tests
2020-04-21 11:58:35 -05:00
Dmitry Verkhoturov 3a0529e51b enable golangci-lint for tests, fix discovered problems 2020-04-21 12:01:10 +02:00
Dmitry Verkhoturov 318feb8ed2 golangci-lint fixes for memory_store tests 2020-04-21 11:47:15 +02:00
Umputun b4a6cc5473 format comment for UserDetail 2020-04-21 02:40:24 -05:00
UmputunandGitHub cb54d0ee76 Merge pull request #691 from dellink/add-error-message-for-anonymous-in-comment-form
Add message of image upload try by anonymous user
2020-04-20 10:38:26 -05:00
Serge Adamovich 84e68b9bc8 Check messages in tests 2020-04-20 11:22:14 +03:00
Serge Adamovich 645b2390c2 Generate locales 2020-04-20 10:35:26 +03:00
Dmitry VerkhoturovandUmputun 513c8f353d sticter golangci config, fix discovered issues 2020-04-19 16:21:09 -05:00
Dmitry VerkhoturovandUmputun 2acb00d424 rename image.Store.SaveWithID to Save 2020-04-19 16:21:09 -05:00
Dmitry VerkhoturovandUmputun 36cee9cc66 simplify image.Store interface 2020-04-19 16:21:09 -05:00
Serge Adamovich 7115329060 Add tests 2020-04-19 20:41:46 +03:00
Serge Adamovich 2f966d09ca Fix work with message in onDrop method 2020-04-19 20:12:44 +03:00
Serge Adamovich 5fc67f1c12 Add message of image upload try by anonymous user 2020-04-18 17:24:54 +03:00
Pavel MineevandUmputun b5e31f3081 Remove unused sed from artifacts build 2020-04-17 13:48:54 -05:00
Dmitry VerkhoturovandUmputun 381ca75126 #614 serve proxified images disregarding if saving is enabled 2020-04-15 20:05:13 -05:00
Dmitry VerkhoturovandUmputun 4d988c427a #641 bump golangci-lint version to 1.23.8 2020-04-15 20:02:55 -05:00
Dmitry VerkhoturovandUmputun 7cebb9e4ee #641 bump CI go version to 1.14 2020-04-15 20:02:55 -05:00
Dmitry VerkhoturovandUmputun c3896934c9 don't use cache in rest tests which don't utilize it 2020-04-14 13:47:27 -05:00
UmputunandGitHub 1c28f44f08 Merge pull request #671 from akellbl4/update/dev-deps
Update dev dependencies
2020-04-14 10:15:46 -05:00
Pavel Mineev 78125ed975 revert formatjs/cli to old version 2020-04-14 11:42:31 +03:00
Pavel Mineev e5df2a0f52 up prettier 2020-04-14 11:42:31 +03:00
Pavel Mineev e552c78491 Update dev dependencies 2020-04-14 11:42:29 +03:00
Umputun 3cb07b9cd6 fix incorrect sinceTime for comments stream 2020-04-14 03:40:10 -05:00
UmputunandGitHub e94af6a059 Merge pull request #685 from umputun/fix_memory_store_tests_panic
remove t.Log from test server.Run in memory_store
2020-04-13 17:34:39 -05:00
Dmitry Verkhoturov 828669ac95 remove t.Log from test server.Run in memory_store 2020-04-14 00:20:42 +02:00
UmputunandGitHub fab32ace10 Merge pull request #679 from akellbl4/frontend-scripts
Frontend scripts and docs updates
2020-04-13 16:49:02 -05:00
Dmitry VerkhoturovandUmputun 4605d567ae make image.Close wait for all images Submit completion 2020-04-13 16:01:51 -05:00
Dmitry VerkhoturovandUmputun 7151698b18 add -race key to backend tests in CI pipeline and Dockerfile 2020-04-13 16:01:51 -05:00
Dmitry VerkhoturovandUmputun afc94adc1b remove log package usage from tests, sort imports 2020-04-13 16:01:51 -05:00
Umputun 360f47e16b go mod for memory_store example 2020-04-13 14:31:58 -05:00
Umputun ee559e7deb remake modules.txt with go 1.14 2020-04-13 14:30:48 -05:00
Pavel Mineev 5e71613d4f add instant commands 2020-04-13 22:03:37 +03:00
荒野無燈andUmputun 9459a2a2f5 notify/email.go: fixup smtp.NewClient second param 2020-04-13 14:02:00 -05:00
荒野無燈andUmputun 200f464925 smtp buildMessage(): flush quotedprintable writer buff explicitly 2020-04-13 14:01:21 -05:00
Pavel Mineev 51fc0f7a92 put all config in separate js files 2020-04-13 21:58:01 +03:00
Umputun d7442ef1a9 switch flags, revendor 2020-04-13 12:30:23 -05:00
Dmitry VerkhoturovandUmputun d6c7e151a0 bump modules 2020-04-13 12:03:26 -05:00
UmputunandGitHub b39874fe08 Merge pull request #684 from quantonganh/i444-arm-32
the correct GOARCH value is arm, not arm32
2020-04-13 11:57:56 -05:00
Quan Anh Tong 7ebea0ca9d Revert "fix artifact filename on arm32"
This reverts commit 2e47fde46e.
2020-04-13 20:20:02 +07:00
Quan Anh Tong cdda2d0319 the correct GOARCH value is arm, not arm32 2020-04-13 20:12:47 +07:00
Pavel Mineev 8d02ad432c add cross-env for npm run check 2020-04-13 16:10:35 +03:00
Pavel Mineev 7d4b6d22f0 show only warn and error messages on install without other visual noise 2020-04-13 16:08:04 +03:00
Pavel Mineev af38a64f5c Frontent infrastructure updates
* rename and combine npm scripts
* move to checkout@v2 in actions
* change dev docs
* move liststaged config to package.json because it easier to understand what husky doing
2020-04-13 14:36:19 +03:00
UmputunandGitHub a15acaf293 Merge pull request #632 from Smolevich/small-improvements
Small improvements
2020-04-13 02:38:20 -05:00
Dmitry VerkhoturovandUmputun dba77d403d bump go-pkgz modules, bbolt new url 2020-04-12 16:09:01 -05:00
Pavel MineevandUmputun 40eb39f8f0 increace deleteme size after update preact 2020-04-12 15:31:17 -05:00
Pavel MineevandUmputun c291dd491d update preact 2020-04-12 15:31:17 -05:00
Pavel MineevandUmputun 78f1f39f33 increase deleteme size 2020-04-12 14:07:16 -05:00
Pavel MineevandUmputun d5ef0db394 Update production deps 2020-04-12 14:07:16 -05:00
lalentineandUmputun 82cbb5a836 Adds background transition to .comment class 2020-04-12 13:17:58 -05:00
荒野無燈andUmputun 4b3e59f8d0 locale: zh.json: fixup typo for subscribeByEmail.submit 2020-04-12 13:16:44 -05:00
Dmitry VerkhoturovandUmputun fc63493e7e return proper content type instead of image/* 2020-04-12 03:57:08 -05:00
Umputun 7614edf167 extract wait in image close 2020-04-12 03:45:03 -05:00
Dmitry VerkhoturovandUmputun a133ab31fa add context cancellation to image.Service.Cancel() 2020-04-12 03:34:13 -05:00
Dmitry VerkhoturovandUmputun 449e375cbb fix image.Service termination in case Submit was never called 2020-04-12 03:34:13 -05:00
Dmitry VerkhoturovandUmputun 97d7467033 fix Docker capitalization in Readme, fix backend run instructions 2020-04-12 03:34:13 -05:00
Pavel MineevandUmputun 053bea18cd Cut react devtools forwarding by webpack in production build 2020-04-12 02:31:34 -05:00
lalentineandUmputun 49dd5bdf21 Resolves #559
Adds .comment_highlighting class after scrolling to comment.
2020-04-11 23:03:47 -05:00
Quan Anh TongandUmputun 2e47fde46e fix artifact filename on arm32 2020-04-11 22:51:01 -05:00
Dmitry VerkhoturovandUmputun 1e67850e87 fix docker run error without attached /srv/var 2020-04-11 20:08:35 -05:00
Dmitry VerkhoturovandUmputun f350e26740 #655 add documentation for using SendGrid and for Mailgun API 2020-04-11 20:07:41 -05:00
UmputunandGitHub c76117d5c6 Merge pull request #656 from akellbl4/docs/add-ram-requirements
Add info about RAM requirements
2020-04-10 12:10:39 -05:00
Pavel Mineev 9660e2f6c2 add info about RAM requirements 2020-04-10 15:41:12 +03:00
荒野無燈andUmputun d336ab5053 add Simplified Chinese locale 2020-04-10 02:52:43 -05:00
Smolevich 6b2bc11710 Merge branch 'master' of github.com:umputun/remark into small-improvements 2020-04-09 21:50:44 +03:00
Pavel MineevandUmputun aecad2fce9 update node deps 2020-04-09 03:35:35 -05:00
UmputunandGitHub 923f182be5 Merge pull request #646 from codiflow/master
Added new language strings
2020-04-08 11:47:24 -05:00
Pavel MineevandUmputun b53f1872bb create type check config by extending default config 2020-04-08 11:47:00 -05:00
Pavel MineevandUmputun 01befe0c29 Fix linter errors 2020-04-08 11:47:00 -05:00
Pavel MineevandUmputun ddafb40b2d Update typescript to 3.8.3 2020-04-08 11:47:00 -05:00
Pavel MineevandUmputun 2a1690afcf Encode email in subscribe request 2020-04-08 11:10:32 -05:00
codiflow 97763c681f Merge remote-tracking branch 'origin/master' 2020-04-08 14:44:43 +02:00
Christian 48aff63eca Added new language strings 2020-04-08 14:43:52 +02:00
UmputunandGitHub a4e262e9e6 Switch back to go 1.13 and lint 1.23 (#647)
* combine lint and test for examples
* combine backend test and lint
* switch linter to 1.23
* back to go 1.13
* comment -mod=mod for 1.13
2020-04-07 15:30:00 -05:00
ChristianandGitHub c7e5059341 Added new language strings 2020-04-07 11:49:48 +02:00
UmputunandGitHub 1bb8fa4b61 Merge pull request #645 from lufte/es-locale
Spanish locale
2020-04-07 03:01:36 -05:00
konstantin krivlenia 879e5c8383 fix default en message form symbol limit 2020-04-07 10:48:00 +03:00
lufte 467496f34c Add translations for "new page" and english improvements 2020-04-06 23:39:40 -03:00
lufte a0d68b12ea Spanish locale 2020-04-06 23:37:11 -03:00
Umputun 9f12cc9f5f cut-paste err 2020-04-06 18:06:33 -05:00
Umputun cadd1e8a7c enforce -mod=mod for example ci 2020-04-06 18:03:54 -05:00
Umputun 73adc23176 try to copy .golangci.yml to _example 2020-04-06 17:22:58 -05:00
Dmitry VerkhoturovandUmputun cab3b8a831 move admin email notifications call to rest/api module 2020-04-06 16:27:19 -05:00
Dmitry VerkhoturovandUmputun 04d3541de1 add admin email notifications on new comments 2020-04-06 16:27:19 -05:00
Dmitry VerkhoturovandUmputun e377bceeb4 make notify.Email support multiple messages in one Send 2020-04-06 16:27:19 -05:00
Dmitry VerkhoturovandUmputun 726a4d7fc3 add translation instructions 2020-04-06 16:18:56 -05:00
UmputunandGitHub 079721298e update lic year 2020-04-06 13:04:50 -05:00
Pavel MineevandUmputun dfeff72d3e update vulnerable packages 2020-04-06 12:07:14 -05:00
Pavel MineevandUmputun 78a5bf15b5 Fix back button in email subscription form 2020-04-06 10:14:50 -05:00
Smolevich a404b8a87a Try to delete parallel flag 2020-04-06 11:27:27 +03:00
Smolevich ca9586491b Merge branch 'master' of github.com:umputun/remark into small-improvements 2020-04-06 10:41:34 +03:00
Umputun 40db689b27 update linter to 1.24 #641 2020-04-06 01:51:23 -05:00
Dmitry VerkhoturovandUmputun 106218df19 fix memory_storage run
- add proper formatting to Readme instructions
- fix docker-compose yaml
- add Enabled flag to admin storage, as without it I saw following error on trying to post a comment:
  ```
  mem_store.r42    | 2020/04/03 15:53:52.433 [INFO]  {logger/logger.go:120 logger.(*Middleware).Handler.func1.1} POST - /cmd - 172.20.0.3 - 200 (25) - 126.8µs - {"method":"admin.enabled","params":"remark","id":29}
  remark42         | 2020/04/03 15:53:52.434 [WARN]  {rest/httperrors.go:85 rest.SendErrorJSON} can't save comment - failed to prepare comment: can't get secret for site remark: site remark disabled - 500 (0) - dev_user/dev_user - /api/v1/comment - [rest/api/rest_private.go:121 api.(*private).createCommentCtrl]
  ```
  Flag was originally added 106c018ef1
2020-04-04 14:06:39 -05:00
Dmitry VerkhoturovandUmputun 0355ba4ff7 add image storage to memory_store 2020-04-04 14:06:39 -05:00
Dmitry VerkhoturovandUmputun ce92f63215 add remote image storage support to backend 2020-04-04 14:06:39 -05:00
UmputunandDmitry Verkhoturov b6ef4f393a switch to go 1.14 #636 2020-04-04 18:38:35 +02:00
UmputunandGitHub 98b77f076c Merge pull request #628 from paskal:img_fixes
image.Service and image.Store improvements
2020-04-02 21:48:41 -05:00
Pavel MineevandUmputun 7de82e0fef Fix clear localstorage after comment sending
* add functions for saving json to localstorage and tests
* remove comment form localstorage after posting
2020-04-02 16:25:37 -05:00
Smolevich 11c7f4ff55 Remove GOPATH from last step 2020-04-02 19:54:09 +03:00
Smolevich 509b4b66bd Recover old name of step and remove GOPATH 2020-04-02 19:52:45 +03:00
Smolevich e8f1e2d6b0 Remove unused config parts 2020-04-01 17:48:10 +03:00
Smolevich be8706efcf Fix path to profile.cov 2020-04-01 17:43:49 +03:00
Smolevich 1be319b4b9 Add working directory arg 2020-04-01 17:40:43 +03:00
Smolevich 0b53c4aeaa Add go env 2020-04-01 17:25:17 +03:00
Smolevich b713d0d3f1 Add GOPATH 2020-04-01 12:53:23 +03:00
Smolevich cede669c20 Add temporary using action 2020-04-01 12:40:49 +03:00
Smolevich 197eb0c9a0 Change ci-test-backend.yml 2020-04-01 12:20:53 +03:00
Dmitry VerkhoturovandUmputun 10c1aa939f remove .golangci.yml file for golangci-lint service 2020-04-01 02:33:39 -05:00
Pavel MineevandUmputun d9f913dbb0 fix ru translate 2020-04-01 02:32:30 -05:00
Dmitry Verkhoturov 4539b8ffb4 hide image.Store from image.Service consumers 2020-03-27 21:03:49 +01:00
Dmitry Verkhoturov e1d502ecf8 add clarifying comment to image.Store interface 2020-03-27 02:24:44 +01:00
Dmitry Verkhoturov 33dde7d4dd move MaxSize, MaxHeight and MaxWidth from image Storage to Service 2020-03-27 02:10:49 +01:00
Dmitry Verkhoturov ef6634e9b3 exclude unused filename from image.Store.Save() parameters 2020-03-26 23:04:49 +01:00
Dmitry Verkhoturov d714e32668 explicitly ignore error in test fmt.Fprint 2020-03-26 23:04:31 +01:00
Dmitry Verkhoturov fa4e779fa0 bump go modules in memory_store 2020-03-26 23:02:08 +01:00
Pavel MineevandUmputun 7ce400a5e5 move getting of hidden users to utils
* get hidden user by util
* add tests for changed actions
* add tests for getting users from local storage
2020-03-25 01:52:46 -05:00
Pavel MineevandUmputun e7dfd1efbb Fix hide/unhide user and setVerifiedStatus
* add tests for user store
2020-03-25 01:52:46 -05:00
Dmitry VerkhoturovandGitHub c9a23698ba Image interface changes (#623)
@paskal 

* sort imports, add missing copyright
* regenerate engine mock
* make all image.Store interface functions public
* go mod tidy
* make image.Store.Load return []byte instead of io.ReadCloser
* separate memory_store example RPC server to multiple files by handlers groups
2020-03-22 20:43:43 -05:00
Pavel MineevandUmputun a883052e03 Fix saving of comment sorting
* Add tests
2020-03-22 16:11:01 -05:00
Yonatan RybinskiandGitHub 1ba5e7a67e Added instructions for SES email configuration (#622)
@cigan1  (Yonatan Rybinski)

* Added instructions for SES
* Fixed domain
* Changed the name to Amazon SES not AWS SES
* Removed the extra URL
2020-03-22 15:57:59 -05:00
1c5928ad0b Add russian translates and update readme (#621)
Author: Pavel Mineev @akellbl4

* add russian translates and update readme

* Fix email adress string

Co-Authored-By: Dmitry Verkhoturov <paskal.07@gmail.com>

* Fix uploading disabled message

Co-Authored-By: Dmitry Verkhoturov <paskal.07@gmail.com>

* Change phrase for empty token string

Co-Authored-By: Dmitry Verkhoturov <paskal.07@gmail.com>

* Fix grammar for invalid email string

Co-Authored-By: Dmitry Verkhoturov <paskal.07@gmail.com>

* change word for thread

Co-Authored-By: Dmitry Verkhoturov <paskal.07@gmail.com>

Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2020-03-19 11:31:43 -05:00
Nikolay KorotkiyandUmputun 79e1009b19 Updated Russian translation 2020-03-18 13:16:34 -05:00
Nikolay KorotkiyandUmputun 61b78a1beb Added Finnish translation 2020-03-18 11:15:30 -05:00
Pavel MineevandUmputun 0363855de8 add license field 2020-03-12 15:52:38 -05:00
Pavel MineevandUmputun 24f0c19378 Update deps
* update vulnerable packages
* set fresh version of node because of using Object.fromEntries
2020-03-12 15:52:38 -05:00
konstantin krivleniaandUmputun 156f8e446f check that all translation messages are commit 2020-03-12 15:45:35 -05:00
Pavel MineevandUmputun fe4b46f924 fix id and generate langs 2020-03-12 15:44:31 -05:00
Pavel MineevandUmputun 0725013481 Hide error message after login 2020-03-12 15:44:31 -05:00
Pavel MineevandUmputun 779839aeb4 Add error on image upload by anonymous user 2020-03-12 15:44:31 -05:00
Pavel MineevandUmputun c9485fab3f Use named function for AuthPanel wrapped component 2020-03-12 15:44:31 -05:00
Pavel MineevandUmputun 93fd445bd4 On-demand auth
* auth block was moved to comment form
* if comments on page read only auth form shows in old place
* comment value in form will be saved between refreshes (it is side effect form saving comment value between unauth and auth states)
2020-03-12 15:44:31 -05:00
Dmitry VerkhoturovandUmputun d85e185aa1 add caddy documentation 2020-03-09 12:59:24 -05:00
Umputun 7497561d9c set anonymose with admin names to blocked #605 2020-03-08 15:37:31 -05:00
Pavel MineevandUmputun 827bc7dc40 Fix sorting 2020-03-08 00:14:03 -06:00
Pavel MineevandGitHub 85c71716f9 Small refactoring (#609)
* Move visibility param from redux store to local state

* use func for parsing location.search

* Remove bad wrapper

* we already have wrapper with NODE_ID on page and we shouldn't another one with the same id on page
* move common part of markup to level up

* Tinny refac of setSorting

* add dummy types for pollyfils

also, a bit rewrited way to resolve imports

* Move sort flag to local store

Because we don't need to share this param between diffrent parts of interface

* Add action creators

* move action to action creators

* Remove unused functions

* Merge in conditions in one

* One way for export API methods

* add default tags

* Rewrited changing read only mode

* removed unused function

* set sort changes

* always send sort from store
* rollback if sort don't  work

* constanst

* add typing
* shorten export
* remove double define of host
2020-03-07 12:25:18 -06:00
Pavel MineevandUmputun 30f3ffe517 Reduce size of last-comments widget
* use one api method insted full api file
* remove debug for preact
* use smaller constans file
* move unique constants
2020-03-07 03:59:03 -06:00
konstantin krivleniaandUmputun ea4e95c710 fix markup for subscribe block for long description 2020-03-07 03:58:34 -06:00
ChristianandUmputun f0eeb37dc0 Fixed typo 2020-03-06 12:22:32 -06:00
ChristianandUmputun 2287e2a889 Added german translation 2020-03-06 12:22:32 -06:00
UmputunandGitHub 75663ee227 Merge pull request #599 from Mavrin/use_css_custom_properties_for_colors
Use css custom properties for colors
2020-03-06 01:21:26 -06:00
konstantin krivlenia f6e73299cd #5 fix after rebase 2020-03-05 12:35:16 +03:00
konstantin krivlenia 6aeb72c3d9 #5 implement POC for theming api 2020-03-05 12:35:15 +03:00
konstantin krivlenia be6aa8314b #5 use css custom properties instead of color constant 2020-03-05 12:34:40 +03:00
b22cd5f76a Add localization (#602)
* #10 add localization

* #10 revert locale from dev page

* #10 add more examples

* #10 localize auth panel

* #10 localize some comment message

* #10 localize some comment form message

* #10 translate vote messages

* #10 translate comment message

* #10 use messages value for translate reference

* #10 fix compile issue

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 use messages value for translate reference

* #10 translate comment message

* #10 translate comment form

* #10 translate comment form

* #10 translate root component

* #10 translate user info

* #10 translate settings

* #10 translate settings

* #10 translate toolbar

* #10 translate errors messages

* #10 sort dict

* #10 fix after rebase

* #10 localize anonymousLoginForm

* #10 localize emailLoginForm

* #10 update size-limit

* #10 localize subscribe by rss

* #10 localize subscribe by email

* #10 add de locale

* #10 increase limit size

* #10 auto generate loadLocale

* #10 add some documentation

* #10 fix error messages

* fix typo

* don't bundle en locale

* fix message id

* change size limits

* Update extract message pattern

Co-Authored-By: Pavel Mineev <pavel@mineev.me>

Co-authored-by: Pavel Mineev <pavel@mineev.me>
2020-03-04 14:56:00 -06:00
konstantin krivleniaandUmputun 5039e8d277 optimize last comment widget 2020-02-22 12:55:25 -06:00
konstantin krivleniaandUmputun ffb41abfd4 optimize bundle sizes 2020-02-22 00:48:22 -06:00
Konstantin KrivleniaandGitHub 390ecbe8d9 Cleanup embed.ts and add limit-size checker (#601)
* Cleanup embed.ts and add limit-size checker

* add  size limit for dist files

* check only size of files
2020-02-19 16:08:35 -06:00
Konstantin KrivleniaandGitHub 11327cee5b Introduce stylelint (#600)
* introduce stylelint config

* run stylelint check on ci
2020-02-16 11:25:59 -06:00
Umputun 1334048d89 allow /api/v1/user calls for robots 2020-02-09 14:41:38 -06:00
Pavel MineevandUmputun 75ff933add Small restyling of comment elements
* quote with left border and without background
* style for old code insetions (structure pre > code)
* unified elemets margins
* 1px separator height
2020-02-09 13:49:58 -06:00
Pavel MineevandUmputun 90bd79abbb Add style for code element
* code-highlight.scss was moved
* css vars was defined in `raw-content`
* `pre` styles was used for `code`
2020-02-09 12:18:56 -06:00
d081bf4a24 Code colors (#592)
* render code htm with classes

* code highlight styles

* revert unneded gitignore change

Co-authored-by: Umputun <umputun@gmail.com>
Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2020-02-09 03:18:42 -06:00
1e548eb85f Code colors (backend support) (#590)
* testing code highlight with bfchroma render

* add bfchroma vendoring

* render code htm with classes

* adjust bfchroma test cases inside formatter

* go mod tidy

* fix markdown code render tests

* adjust comment text sanitizer to permit code HTML classes

* go mod tidy

* add chroma classes list regexp

* unify gitignore entries style

Co-authored-by: Umputun <umputun@gmail.com>
2020-02-08 21:11:12 -06:00
UmputunandGitHub b566d7f844 Merge pull request #589 from paskal/blackfriday
Replace blackfriday.v2 with blackfriday/v2
2020-02-07 15:25:37 -06:00
Dmitry Verkhoturov 62c43cce77 replace blackfriday.v2 with blackfriday/v2 2020-02-07 22:15:05 +01:00
Pavel MineevandUmputun d22e364d1b Autosubmit token by paste and a bit more (#583)
* Use lib for typing tests instead copied types

* add jwt utils

* Add autosending

* fix

* tests

* remove atob and fix tests
2020-01-28 00:38:02 -06:00
019fa9e5ac Simpler image service (#580)
* fix potential race on close

* move validate inside

* demote commit and cleanup in image.Store to non-exposed functions.

* replace immediate image commit with delayed via Submit

* minor: remove error logging, rename tests

* minor: err wrapping, comments wording

* clarify FileSystem.Save code

* attempt to fix #584 by making submitted image commits on a half of TTL

Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2020-01-27 14:17:58 -06:00
Umputun be3e5f0246 set exclude-use-default: false for linter 2020-01-27 14:17:05 -06:00
Artur MullakhmetovandUmputun 22a88c579f Fix unstable test
Make comment consistent with code
Decrease timeout param to prevent unwanted 200 response
2020-01-26 11:35:17 -06:00
Artur MullakhmetovandUmputun dcab5a1622 switch image interpolation to CatmullRom 2020-01-26 11:35:17 -06:00
UmputunandGitHub 5ecea4f80c Update FUNDING.yml 2020-01-26 11:30:49 -06:00
Pavel MineevandUmputun 1804571d4d fix 2020-01-24 00:17:32 -06:00
Pavel MineevandUmputun 45f929868a fix name line break and add styles for blockquote 2020-01-23 21:45:32 -06:00
Dmitry VerkhoturovandUmputun f97d8232ed Add subscription link support to notification email verification template (#573)
* add subscription link support to notification email verification template

* always show token for email subscription in verification email

* hide SubscribeURL from users
2020-01-20 18:27:23 -06:00
Pavel MineevandUmputun 091ac9b4c1 Add shallow equality 2020-01-20 13:17:27 -06:00
Vyrtsev MikhailandUmputun 029f50c52c do not render dummy for comment once it gets into viewport 2020-01-19 12:51:28 -06:00
Dmitry VerkhoturovandUmputun 8c37fac06e actualise CLI options information in the readme 2020-01-19 04:10:55 -06:00
Umputun 4d2ed6bde4 typo in version info print 2020-01-18 22:42:51 -06:00
Umputun f30f9b97af missing methods comments 2020-01-18 22:39:21 -06:00
Anton PanferovandUmputun d69495a9e4 Preserving external images (issue #399) (#447)
* added passing userID to formatters

* added images preserver

* pinned version of golangci-lint

* implemented caching in proxy

* added tests for caching proxy

* removed previous implementation

* removed passing userID into formatter

This reverts commit 05014a5b24190836958377fcd7304294ed02f061.

* made linter happy

* removed storing image extension for FS store

* fixed bug with context cancelation preventing reading downloaded image

* fixed bug with trusting return value and ignorring error

* updated README

* added nolint for sha1

* removed unused resized flag

* added backward compatibility for config

* added handling deprecated cli parameter

* corrected func comment

* made linter happy

* made linter happy
2020-01-18 22:31:30 -06:00
Dmitry VerkhoturovandUmputun 0b55c4f7ad lower Cache-Control max-age from 24h to 1h to increase speed of updates delivery 2020-01-18 17:58:58 -06:00
Dmitry VerkhoturovandUmputun 540d828ea1 fix ParentCommentDate in email template, fix #561 2020-01-17 17:19:19 -06:00
Pavel MineevandUmputun a3d038f75b fix 2020-01-16 16:42:07 -06:00
Dmitry VerkhoturovandUmputun 8148650247 Update documentation for email capabilities (#555)
* replace deprecated email variables names with new ones

* clarify email authentication instructions

* add email notifications documentation

* add explanation screenshots

* fix punctuation and typos
2020-01-16 11:20:33 -06:00
Umputun b0898cd1f3 add cache control for frontend assets 2020-01-15 15:07:45 -06:00
Pavel MineevandUmputun b082c2a82f Fix
*  fix score position
* add margin before bloquote if it is placed after text
2020-01-15 02:44:05 -06:00
Pavel MineevandUmputun 4f38c3ea31 FIx height of comment info 2020-01-15 01:50:10 -06:00
Pavel MineevandUmputun 658279379a Update email templates (#550)
* Update email templates

* Fix README

* fix detail
* fix typo

* Changes connected with comment at issue

https://github.com/umputun/remark/issues/494#issuecomment-570801318

* Unify styles between templates

* remove breaking words

* use the same prefix for parent and child
2020-01-15 01:22:00 -06:00
Pavel MineevandUmputun ce01a347ca Collapse comment by thread border (#545)
* collapse comment by border

* Fix after review

* hide avatar in collapsed state
* fix lines icon
2020-01-14 17:11:30 -06:00
Dmitry VerkhoturovandUmputun d0ddd8aa80 Unify SMTP settings in separate section (#536)
* move SMTP settings to separate group

* move deprecated options in separate section in readme

* adjust variables in docker-compose

* add description to SmtpGroup

* remove SMTP option setting which is already set to same value

* remove smtp port default for consistency

* add server deprecated functions handling

* satisfy linter

* add missing bracket in description

* add test for handleDeprecatedFlags

* add HandleDeprecatedFlags function to CommonOptionsCommander

* improve HandleDeprecatedFlags behavior

* add missing result check to ServerCommand.HandleDeprecatedFlags
2020-01-14 14:49:39 -06:00
Konstantin KrivleniaandUmputun 17be003652 #378 Emoji suggestions (#546)
* #378 add emoji suggestion

* #378 use lazy load for nodeEmoji

* #378 apply remark styles only for light theme

* #378 add dark theme

* #378 fix test

* #378 use permanent class names

*  #378 flip if else for readability
2020-01-13 18:26:32 -06:00
Umputun 3c12fde0f8 fix .golangci.yml location inside docker build 2020-01-13 18:19:57 -06:00
StasandUmputun b2176a175d Use golangci lint action (#549)
* Use action insted download binary file

* Update ci

* Add config option

* Use pre-built docker image for golangci-lint-action

* Remove pre build docker action

* Update action name

* Remove env

* Update ci file

* Add working directory

* Change GITHUB_WORKSPACE

* Update ci file

* Fix ci

* Comment golangci-lint-action

* Update working-directory

* Fix path

* Update ci-build.yml

* Update path

* Revert changes

* Update .golangci.yml

* Add new path for triggers ci

* Separate steps

* Add working-directory

* Add test pwd and ls

* Fix working directory

* Update script for linting in Dockerfile

* Move .golangci.yml to dir backend

* Delete unnecessary word

* Remove multiline string

* Reformat multiline strings to single

* Update disable-all option
2020-01-13 10:56:56 -06:00
Pavel MineevandUmputun 3447afffdd fix position on "show more" button 2020-01-12 13:01:04 -06:00
Pavel MineevandUmputun 7b67b10d5d Run es-check by cli 2020-01-08 16:18:39 -06:00
Pavel MineevandUmputun 7d9e8ce076 UI for email subscription (#537)
* Add api methods for subscription

* Small changes

* little changes in remark.tsx
* disallow pass className to Button and Input

* Subscribe block

* add RSS and  Email subscription drobdowns
* add hook useTheme
* unify dropdown import/export

* Change API

* rename subscribe methods
* add unsubscribe method

* Add email subscription to settings and user

* Refactor and add new steps

* render by single component
* add final step
* add unsubscribe step it user is subscribed

* Add tests

* Update subscription logic

* test without mocking redux methods but with mocking store
* update user in store after subscribe and unsubscribe
* little changes in subscription flow

* fix drobdown size

* Fix RSS subscription link for site

* fix link
* add test for RSS subscription

* Fix showing email subscription

* it don't show to unauth users
* it don't show to anonymous users
* it tested
* isUserAnonymous is a bit rewrited
* isUserAnonymous is tested

* Make Email button visible for anon users

* React X supporst Fragments thats why .babelrc changed
* disabled button is more visible
* fix hovering on disabled buttons
* create mocks for tests
*  move email dropdown to __subscribe-by-email
2020-01-08 13:55:50 -06:00
Pavel MineevandUmputun bfa308e8da Allow voting for anonymous users 2020-01-07 23:43:49 -06:00
Pavel MineevandUmputun 5ee76febf2 Update vulnerable dependencies 2020-01-07 18:33:23 -06:00
Dmitry VerkhoturovandUmputun e4d982dbca Delete user email on /deleteme request (#540)
* delete user email on deleteme request

* add tests for user email deletion in /deleteme request
2020-01-07 14:49:24 -06:00
Dmitry VerkhoturovandUmputun eeb9c72248 check user equality for email notifications only by ID 2020-01-07 14:25:27 -06:00
Pavel MineevandUmputun a309fc8fb0 Refactor preloader
* make it simple (use only one DOM element for preloader)
* remove useless test (it tests nothing)
* unify preloader import/export
2020-01-07 11:14:59 -06:00
Pavel MineevandUmputun 78bb9dd153 Add auto-changing theme for development
Theme is changing follows to OS settings.
2020-01-07 03:57:34 -06:00
Pavel MineevandUmputun c660d2a522 Change text sign in to login 2020-01-05 18:35:22 -06:00
Pavel MineevandUmputun 07137b34e7 Run prettier on all files 2020-01-05 17:52:25 -06:00
Pavel MineevandUmputun 51d2310c1f Nice inputs and buttons (#510)
* Set default font in examle

* prettify file

* add ui elements

* Use UIButton at AuthPanel

* sort deps
* remove unsed getUserTitle method
* move UserId inside AuthPanel
* tests

* use UIButton and UInput in Anonymous login

* Use UIButton and UIInput in email login

* Replace Button to UIButton

* fix context for onTitleClick in dropdown
* rearrage class props in dropdown
* TODO: change finding over DOM to using ref

* Use UIButton in input

* rearrage deps

* Use UIButton in comment

* test

* Focus and Input buttons

* custom focus style for inputs and buttons
* the same focus style for comment input
* align buttons by top line in input

* Token dropdown & hover fix

* disable hover when button disabled
* make token dropdown markup in new style

* Fix trailing comma

* update prettier
* move prettier config to json format because it is more hendy for settings
(for example vscode can suggest rules)
* prettier fixed trailing comman in ejs

* Remove unnecessary conditions

* Files was formatted by Prettier

* fix cursor pointer on collapse button

* Move components and right naming

* ui-button -> button
* ui-input -> input
* input -> comment-form

* Change cursor behavior on disabled state

* Change button style

* font-weight: normal by default
* add small border-radius

* Change another one button to component

* Fix autofocus on username in email login form

* use preact inbuild autoFocus
* fix autofocus on back from token step
* sleep for 0s enough to wait next render before focus
* use more specific name for username input

* fix line-height at auth line on mobile

* fix className
2020-01-05 14:58:10 -06:00
Umputun aea7724769 populate version info to the local builds 2020-01-05 14:36:53 -06:00
Pavel MineevandUmputun c49b88d930 I removed build actions from test part 2020-01-05 14:17:30 -06:00
Dmitry VerkhoturovandUmputun d15176f36d Add email subscription info to User information (#528)
* replace store.Locator with SiteID where only it is used

* add EmailSubscription flag to User information

* add /user endpoint test for email subscription
2020-01-05 13:42:16 -06:00
Pavel MineevandUmputun 2efab67640 fix warnings 2020-01-05 12:59:35 -06:00
Pavel MineevandUmputun 01f19ea511 fix rtl typing in ie11/edge 2020-01-05 12:58:54 -06:00
tim.ivaikinandUmputun a4d10f54dd Support for 3x devices including 11 Pro and MacPro 2020-01-05 12:58:25 -06:00
tim.ivaikinandUmputun 0462cbcd9e Added retina display support 2020-01-05 12:58:25 -06:00
Umputun d0925ba95f ident ci lists for better readability 2020-01-04 21:09:27 -06:00
Umputun d06a27193c add codeowners file 2020-01-04 20:49:56 -06:00
Dmitry Verkhoturov c56c39c59d separate build and test CI workflow, separate frontend and backend tests 2020-01-04 20:53:39 +01:00
Dmitry VerkhoturovandUmputun b7980849b2 improve TestService_WithDrops failure output 2020-01-04 12:53:31 -06:00
Dmitry VerkhoturovandUmputun 88e46446b6 remove empty lines from dockerfile 2020-01-04 12:53:31 -06:00
Umputun 3307394932 don't run any test on rundev target, use private compose 2020-01-04 01:36:44 -06:00
Pavel MineevandUmputun c69e375e45 Add "simple view" mode (#502)
* Add "simple view" mode support

It just hide elements from view when SIMPLE_VIEW recived from server

* Fix typing and add ts check before push

* proper input styling

* Changes for frontend dev compose

* remove SIMPLE_VIEW from default settings for forntend dev
* add private compose to gitignore

* Added simpleView mode for replay and edit modes.

* `simpleView` changed to required param

* FIx border-width in reply form

* Fix border-width in editing mode
2020-01-04 01:31:06 -06:00
Dmitry VerkhoturovandUmputun 4cae7f0324 make TestServerApp_MainSignal more robust 2020-01-02 14:35:50 -06:00
Dmitry VerkhoturovandUmputun 07a14edc3b adjust name for CI build with frontend tests 2020-01-02 14:35:50 -06:00
Umputun b434f52aa1 resolve potentially concurrent writes to bolt in service tests 2020-01-02 14:02:16 -06:00
UmputunandGitHub ffc81f1f78 Merge pull request #519 from akellbl4/fix/hide-error-in-email-form
Hide error after repeated request in email login form
2020-01-02 11:54:50 -06:00
Pavel Mineev ff83b446b8 Fix error hiding after repeated request 2020-01-02 15:19:13 +03:00
UmputunandGitHub 82d0cb97d1 Merge pull request #518 from mullakhmetov/close-testserver
Shutdown test servers
2020-01-01 16:13:37 -06:00
Artur Mullakhmetov 2d942f0a2c Shutdown test servers 2020-01-02 00:29:43 +03:00
Artur MullakhmetovandUmputun 12b916b097 Make test offline friendly 2020-01-01 15:14:26 -06:00
Dmitry VerkhoturovandUmputun 6f9c87850a Adjust tests timouts to work on machine with HDD (#516)
* increase timeout for TestServerAuthHooks http client

* replace assert.Equal checks for slice length with require.Equal

* unify channel name across tests

* fix panic in Test_Main

* increase TestRest_CreateWithPictures timeout for HDD slowness

* increase TestService_VoteSameIPWithDuration timeout for HDD slowness

* increase go test timeout for HDD run

* increase TestRest_CreateWithPictures timeout for HDD slowness
2020-01-01 14:46:39 -06:00
Pavel MineevandUmputun 25782d7d3e fix ts error 2019-12-30 15:53:33 -06:00
Umputun 4352c86d1f Merge branch 'master' of github.com:umputun/remark 2019-12-30 15:31:05 -06:00
Umputun 9660f7ea7e add more private composes to ignore 2019-12-30 15:25:13 -06:00
Dmitry VerkhoturovandUmputun a1f8ad7bd8 expose email_notifications flag on /config endpoint (#514) 2019-12-30 15:05:55 -06:00
Dmitry VerkhoturovandUmputun dbd83a1f0d Fix flapping TestRest_ tests (#513)
* replace single-member wait groups with channels

* increase TestRest_LastCommentsStreamSince comment write delay

* increase timeout for TestRest_InfoStreamCancel

* increase TestServerApp timeout

* adjust waiting time in TestRest_LastCommentsStreamSince in attempt to fix false positive

* adjust comments waiting location in multiple TestRest_ tests
2019-12-30 14:31:49 -06:00
Dmitry VerkhoturovandUmputun 20f667bd1d Improve TestMigrator_* tests reliability (#512)
* simplify api tests set up

* close DataStore\Engine in all test code after usage
2019-12-30 13:41:24 -06:00
Dmitry VerkhoturovandUmputun f416c6c5eb Fixes for multiple tests (#511)
* improve TestServer* reliability

* improve TestService_UserReplies reliability

* increase timeout for Test_Main

* improve TestRest_CreateWithPictures readability and reliability

* introduce random port to REST over SSL tests

* tinker TestRest_InfoStreamSince to have more slack before failure

* finalize test errors check unification

* simplify prepServerApp in cmd package tests

* improve TestRest_InfoStreamCancel reliability
2019-12-30 12:09:05 -06:00
Dmitry VerkhoturovandUmputun 62cc504600 Improve Test_Main reliability (#509)
* adjust TestServerApp_WithSSL to use sslPort in all test checks

* make Test_Main reliable and remove 5s sleep

* make test finishing reliable using "done" channel for TestServerApp*
2019-12-30 03:44:10 -06:00
Dmitry VerkhoturovandUmputun fddb737657 Improve TestServer* tests reliability (#508)
* explicitly ignore error from test connection close

* add client with timeout to places which used default http client

* move random port creation and waiting for server in separate function for reuse

* improve TestServer tests robustness

* move all server waiting code in tests to separate functions

* change chooseRandomUnusedPort to try to listen to port before return

* fix waitForHTTPSServerStart
2019-12-29 20:46:22 -06:00
Dmitry VerkhoturovandUmputun b82be0cc2d Check if the port already in use in TestRPC_ tests server startup (#506)
* check if the port already in use in TestRPC_ tests server startup

* check for open port instead of for HTTP server listening
2019-12-29 18:24:42 -06:00
Dmitry VerkhoturovandUmputun f319cf3c05 fix "address is already in use" for TestRest_RunStaticSSLMode and TestRest_RunAutocertModeHTTPOnly (#507) 2019-12-29 17:47:20 -06:00
Dmitry VerkhoturovandUmputun 36b4f6774c make TestRPC tests reliably wait for HTTP server to start (#505)
* make TestRPC tests reliably wait for HTTP server to start

* adjust the wait period to be limited by 3 seconds
2019-12-29 16:23:47 -06:00
Dmitry VerkhoturovandUmputun 828aeda9b1 fix flapping TestFsStore_Cleanup (#504) 2019-12-29 16:02:36 -06:00
Dmitry VerkhoturovandUmputun 7bab10ab57 Replace "(Not)Nil" test checks for errors with with "(No)Error" (#503)
* replace "(Not)Nil" test checks for errors with with "(No)Error"

* unify error text checks style

* clarify error check in tests in memory_store
2019-12-29 15:42:39 -06:00
Umputun fe4e38a5b1 add make targets for backend build and test 2019-12-29 13:27:36 -06:00
Dmitry VerkhoturovandUmputun 9df2b2a9ed Move email unsubscription endpoint outside of API and make it HTML (#500)
* move email unsubscription page outside of API and make it HTML

* make separate HTML template for SendErrorHTML

* fix error template name

* add test for SendErrorHTML, introduce MustExecute function

* fix content check in test of TestSendErrorHTML

* fix logging test to be more generic and not depend on line numbers
2019-12-27 01:04:57 -06:00
Dmitry VerkhoturovandUmputun b055c61be7 Add backend support for anonymous voting (#501)
* add backend support for anonymous voting

* add test for anonymous user voting

* clarify test code
2019-12-26 17:42:03 -06:00
UmputunandGitHub 151809825a Merge pull request #499 from paskal/improve_service_tests
Add unit tests for service operations with user details
2019-12-22 16:04:57 -06:00
Dmitry Verkhoturov 997127f33e add tests for service operations with user details 2019-12-22 22:23:37 +01:00
Dmitry VerkhoturovandUmputun fde6f520c9 improve email notifications tests coverage (#498) 2019-12-21 11:31:01 -06:00
Umputun 3eacd6f201 add freebsd binary build target 2019-12-18 07:58:28 -06:00
Umputun 3b06571f62 inject version number to bin builds #456 2019-12-17 03:22:10 -06:00
Umputun 0e19d11df8 reformat mock 2019-12-17 02:54:58 -06:00
Dmitry VerkhoturovandUmputun 6a5cd79074 docker-compose format and comment fixes (#492)
* fix typos in readme

* unify format of docker-compose files, fix comments
2019-12-17 01:36:10 -06:00
Dmitry VerkhoturovandUmputun d23d119d70 Add API methods for setting and deleting email (#483)
* add API methods for setting and deleting email

* fix service.SetStringUserDetail signature to return string

* switch table test with description to t.Run()

* remove debug logging

* clarify error handling, functions names

* add email integration test

* add information about email subscription to readme

* change email API calls method from PUT to POST

* typo fix, remove unneeded capturing of range variable

* email test draft

* fix notify mock, email notification test draft

* add MockDestination to startupT return

* fix tests

* add email retrieval for notifications sending

* fix mock for notify

* rearrange mock notify declaration

* add GET /email API handler, fix typos

* revert startupT signature change

* get rid of startupTWithDest workaround

* add rest examples for rest notification

* improve email messages formatting

* fix email send repeater location

* remove unneeded context from sendMessage

* change signatures of buildMessage functions to have same field name

* add missing authenticate call on TLS connection

* add dev user auth token to email requests

* change email verification template

* email code and tests cleanup

* replace fixed spaces with normal ones

* human-readable variables names for new comment reply notification

* rename Comment to CommentText

* add html for comment email notification

* fix comment notification html style

* fix email test

* fix notify email messages rendering

* fix comments on rest examples for email

* explicitly state email notify email template fields

* clarify email API documentation

* change email test not to check quoted-printable part of message

* Fix link color, add unsubscribe link

* fix rest examples tokens

* add UnsubscribeLink support to Email

* add unsubscribe email handler

* fix new reply notification email style
2019-12-16 16:39:55 -06:00
Slava SemushinandUmputun 45fa60f5a1 README.md: fix a typo in a variable name from an example (#491)
s/TIME_ZINE/TIME_ZONE/
2019-12-15 21:28:40 -06:00
Dmitry VerkhoturovandUmputun 30c42ce36b Multiple CI and code fixes (#486)
* enable golangci-lint for momeory_store example

* add race_test option to makefile

* prune lost goroutine in TestRest_Shutdown

* run race tests without cache

* fix ci pipeline

* fix typos
2019-12-09 12:30:07 -06:00
Dmitry VerkhoturovandUmputun b40cb7866b Email sender implementation (#471)
* email sender initial implementation

* add VerificationMetadata into notify.Request

* multiple review fixes

- customizable verification notification message subject
- clarify autoFlush nature in its commentary
- defer writer.Close in Email.sendEmail instead of calling it manually,
  potentially missing the call if error happened between the creation and
  closing.

* add explanatory commentary to notify.Request structure

* fix TCP connection timout commentary typo

* improve table tests presence

* introduce parallelism to tests

* abstract smtpClientWithMaker away

* fix incorrect Email reference in Email.sendMessages

* naming fixes, remove t.Parallel() from tests

* consistent space in commentary

* rename sendEmail to smtpSend, rearrange variables definitions

* switch Email to create new connection for every Send request

* fix tests for connection-per-submit email sending

* fix tests

* simplify sender object
2019-12-02 18:59:32 -06:00
UmputunandGitHub ea8ac08c72 Merge pull request #480 from paskal/email_interface_clarification
Switch notify.Submit to receive notify.Request instead of store.Comment
2019-11-23 13:22:17 -06:00
Dmitry Verkhoturov 6c73a38c49 switch notify.Submit to receive notify.Request instead of store.Comment 2019-11-23 13:24:57 +01:00
Dmitry VerkhoturovandUmputun ddd466ec41 implement user details storage (#469)
* implement (strings) user details storage

* add rpc user details implementation

* return error from getUserDetail, rewrite tests to table tests

* make UserDetails store UserDetailEntry instead of strings

* update comment about user_details

* fix confusing return

* add user details support for memory store

* add engine.UserDetailEntry to service.UserMetaData

* add ListDetails support to memory storage

* add user details support to native migrator, ListDetails func to storage

* go mod tidy for memory storage

* increase memory storage test coverage, fix tests naming

* add ListDetails tests to memory storage

* add engine.ListDetails and  service.[Set]Metas tests

* change Fprintf to Fprint (triggered by explicitly ignoring error)

* remove Delete from engine.UserDetail, implement list via same method

* adjust service.Metas to new engine.UserDetails signature

* introduce engine.UserDetail("all") consonant

* fix Meta user detail retrieval

* extend store implementations Delete method with UserDetail deletion

* make UserDetail test answer order-independent

* fix flaky test check in TestMemData_FlagListBlocked

* delete user details alongside with comments on deleteme request

* add tests to UserDetail store.Delete implementations

* clarify engine module user details consonants names

* update comments to reflect current state of code

* check for value absence instead of it's length

* revert unneeded code change

* add extensive commentary on UserDetail return type

* remove unused check condition

* clarify UserDetail tests to be truly stateless

* add clarifying comment for pre-table test
2019-11-22 02:24:26 -06:00
Dmitry VerkhoturovandUmputun 773da16649 Improve memory store tests reliability (#479)
* remove unnecessary GIT_BRANCH export

* go mod tidy

* fix tests naming, improve coverage

* make flaky TestMemData_FlagListBlocked reliable
2019-11-17 15:47:01 -06:00
Umputun 6f969a0a76 fix _example test call 2019-11-11 18:55:23 -06:00
Umputun ae40fbeaf4 add example test 2019-11-11 18:52:13 -06:00
Umputun 21e6360ca4 update deps and vendor 2019-11-11 18:50:30 -06:00
Umputun 6abb2727c4 switch to lcw cache 2019-11-11 18:50:09 -06:00
Umputun f86f7dcc23 typo 2019-11-11 09:33:20 -06:00
Umputun b41c539d48 Merge branch 'master' of github.com:umputun/remark 2019-11-11 09:29:39 -06:00
Umputun 5d50f0d794 typo 2019-11-10 17:02:34 -06:00
UmputunandGitHub ffec9272ce Merge pull request #475 from Smolevich/fix-readme
Add href for new paragraph in README.md
2019-11-10 16:23:54 -06:00
Umputun 0672cb41d4 change From email to example.com 2019-11-10 16:17:41 -06:00
Umputun f821049d34 add docs about email 2019-11-10 13:13:43 -06:00
Umputun c5c065975a adjust tests for updated error logs 2019-11-09 13:50:14 -06:00
Umputun 731bcb9606 remove caller ip from logs on http errors 2019-11-09 13:45:36 -06:00
Umputun 0ebf5e1822 make docs to allow easier contributions #422 2019-11-09 13:33:36 -06:00
Umputun 9831f75ea9 promote rest errors to WARN level 2019-11-09 12:34:19 -06:00
Umputun 96d19afbb4 correct twitter auth description
https://github.com/umputun/remark/commit/38e9dc97e63890c3bd65970e888bf7d47c47e1ff
2019-11-09 12:14:57 -06:00
Shupilkin Stanislav Yurevich 2e473ae1b8 Add href for new paragraph in README.md 2019-11-08 18:24:34 +03:00
Umputun 38e9dc97e6 add auth twitter info 2019-11-08 01:54:13 -06:00
Umputun 18ae77a806 longer streamer timeout in cancel test 2019-11-06 17:01:43 -06:00
Umputun 9269666931 add simple-view param and env (SIMPLE_VIEW) #474 2019-11-06 13:47:45 -06:00
Dmitry VerkhoturovandUmputun 7536e265b2 Change coveralls to use build-in GITHUB_TOKEN (#465)
* change coveralls to use build-in GITHUB_TOKEN (should work in forks)

* send GITHUB_REF as GIT_BRANCH

* add debug output

* change service to github

* remove debug output

* move COVERALLS_TOKEN to environment, remove argument
2019-11-05 18:13:25 -06:00
Umputun 4c7e454d86 fix login url with wrong ? #463 2019-11-05 12:45:06 -06:00
Umputun 95fa79241b add details to aud mismatch message 2019-11-05 12:19:01 -06:00
UmputunandGitHub 9b570ae273 change to github sponsors 2019-11-04 18:59:07 -06:00
Umputun dff1e97604 remove ci debug prints 2019-11-01 03:08:13 -05:00
Umputun 524e963130 show GITHUB_EVENT_PATH content 2019-11-01 03:03:18 -05:00
Umputun 8494296b85 fix out lcov name 2019-11-01 02:31:30 -05:00
Umputun aa5ebda1eb convert 2 lcov manually 2019-11-01 02:27:32 -05:00
Umputun 9c5d1840ae use short path for coverage reports 2019-11-01 02:18:30 -05:00
Umputun 8bfe66e725 split coveralls 2019-11-01 02:13:04 -05:00
UmputunandGitHub 37b2f48144 Actions (#458)
* remove coverage report from docker, add build and test step to actions

* install deps to actions

* formatting fix

* install go 1.13

* change linter location
2019-11-01 02:01:24 -05:00
Umputun b706bbaf05 trigger rebuild 2019-10-31 23:40:42 -05:00
Umputun 93c9a2e608 remove service selection 2019-10-31 23:19:57 -05:00
Umputun a60128a957 pass GITHUB_REPOSITORY to ci 2019-10-31 22:58:46 -05:00
Umputun cd5f0b3b91 typo in passing env to ci docker build 2019-10-31 22:34:53 -05:00
Umputun 93103534bc ling: nolint for choice in flags 2019-10-31 22:20:32 -05:00
Umputun cdcf5171cf lint: new warnings from 1.21 golang-lint 2019-10-31 22:09:28 -05:00
Umputun ecb804ce41 update coveralls badge link 2019-10-31 21:56:40 -05:00
Umputun e9cf8ca69e pass more env to ci build to make coveralls happy 2019-10-31 21:43:26 -05:00
Umputun 3ad37810e6 add build compat with actions 2019-10-31 21:05:55 -05:00
Umputun c154fae28f rename actions, replace travis badge by action's 2019-10-31 20:38:13 -05:00
UmputunandGitHub 776b9117fc CI changes (#457)
* make tests more resilient

* adjust drone for v1.x and dev-box, remove travis support

* add backend build step on branch for drone

* fix from email

* change notif email

* less demanding test wait

* add settings section to drone plugins

* adjust branch build

* convert all drone ci docker confs

* rename drone targets
2019-10-31 20:20:18 -05:00
Umputun 3a7ae4f616 revert increase import expired timeout 2019-10-29 03:56:36 -05:00
Umputun 9bd4519e48 increase import expired timeout 2019-10-29 03:51:13 -05:00
Umputun 653238bc99 increase test timeouts 2019-10-29 02:43:11 -05:00
Umputun f859239bdf faster import test 2019-10-28 23:34:36 -05:00
Umputun ab0866cb77 randomize bold db for native migrator test 2019-10-28 22:19:14 -05:00
Umputun 488f9bedcf add image.bolt.file info 2019-10-28 21:40:27 -05:00
UmputunandGitHub 9be7925246 Merge pull request #454 from Reeywhaar/preact-10
update preact to preact 10
2019-10-27 19:43:36 -05:00
Vyrtsev Mikhail a2e508cc1e update deps 2019-10-28 02:46:34 +03:00
Vyrtsev Mikhail 02b2895cac update Preact to Preact X 2019-10-28 02:30:49 +03:00
Vyrtsev Mikhail 4240a4d6ee update minor deps 2019-10-27 23:22:58 +03:00
Vyrtsev Mikhail 4f3400e594 update Comment type 2019-10-27 23:22:57 +03:00
Umputun eb67ba85d1 update deps 2019-10-27 13:57:10 -05:00
Umputun 6ad2263859 Merge remote-tracking branch 'origin/master' 2019-10-27 13:32:40 -05:00
UmputunandGitHub cc5e8a6356 Merge pull request #453 from StaroKep/fix/452_dark-theme-shows-white-preview
Dark theme shows white preview #453
2019-10-24 15:56:21 -05:00
Kirill Eremin 64b2830b5b fix 2019-10-24 23:37:17 +03:00
VdimirandUmputun b7045d9771 Add bolt image storage (#449)
* Add bolt image store support

* Fix lint errors

* Use separate bolt buckets for staging and commited

* Comment bolt store public entities

* Fix spelling
2019-10-22 02:35:48 -05:00
Umputun 58f92062d9 Merge branch 'master' of github.com:umputun/remark 2019-10-19 13:05:27 -05:00
UmputunandGitHub d7faf2df0e Merge pull request #448 from Vdimir/backend-test-timeout-param
Add parameter to change backend test timeout
2019-10-19 12:42:43 -05:00
VDimir 13bdf8358a Add parameter to change backend test timeout 2019-10-19 12:48:49 +03:00
Umputun 7644259a38 update deps 2019-10-17 15:50:59 -05:00
UmputunandGitHub a310e222d9 Merge pull request #442 from umputun/twitter-auth-70
add twitter to the list of providers on frontend #70
2019-10-10 11:29:39 -05:00
Umputun d14f7e2c47 add arm target arch #444 2019-10-10 00:33:10 -05:00
UmputunandGitHub 71742f5cfa Merge pull request #443 from smaant/fix-development-docs
Fix docs
2019-10-09 16:36:14 -05:00
Anton Panferov 43ebc61eca removed mongo mentions 2019-10-09 11:26:56 -10:00
Anton Panferov 74a58562f0 corrected non docker run command 2019-10-09 11:25:50 -10:00
Umputun da63dd2c78 add twitter to the list of providers on frontend #70 2019-10-08 03:14:24 -05:00
Anton KosourovandUmputun 4dc123dbe8 Change url in comments, decorate approach (#431)
* WIP: url mapper, wrapped reader approach

* create url mapper on start

* add pattern matching in mapper

* check pattern matching in test

* change site-id from radio-t to remark42 in tests

* create new url mapper on demand, based on given func, union strict and prefix rules

* rename convert to remap

* add import with mapper test

* rename mapper func to UrlMapperMaker, create comments in test via data service

* move /import/wait to /wait ctrl

* add remap cmd

* fix url naming
2019-09-30 13:12:42 -05:00
Misha VyrtsevandUmputun 6b2addc280 Fix #430 (#440)
* add postMessage util

* use scrollTo via postMessage (fixes "scroll to parent" on Safari #430)

* use postMessage
2019-09-29 15:38:09 -05:00
Misha VyrtsevandUmputun 9fbdff106d Update deps 2019 09 (#439)
* update minor deps

* update minor deps

* update major deps

* fix eslint warnings
2019-09-29 14:09:00 -05:00
UmputunandGitHub ea2a15ea8e add patreon link 2019-09-27 12:03:16 -05:00
Umputun 1cbedb85b0 fix -time sort for user comments 2019-09-26 23:06:20 -05:00
UmputunandGitHub 8cf611690a Merge pull request #437 from bessarabov/patch-1
Added missing comma to the snippet code in README
2019-09-22 13:33:24 -05:00
Ivan BessarabovandGitHub 851c9dc3b0 Added missing comma to the snippet code in README 2019-09-22 16:19:45 +03:00
StasandUmputun 582c9d6bce Fix problem with DEBUG variable (#435)
* Fix problem with DEBUG variable

* Add condition for starting ci on pull requests

* Add default value

* Add default value in another place

* Fix error in bash statement

* Replace && on if condition
2019-09-20 15:12:50 -05:00
StasandUmputun 4f6c336f6c Use GitHub actions as ci (#434)
* Add blank.yml

* Fix ci

* Update blank.yml

* Update blank.yml

* Add debug

* Show environments variables

* Add test env for travis

* Update blank.yml

* Update formatting

* Add env variable GIT_BRANCH

* Change setting CI

* Add quotes for env variable value

* change on in ci
2019-09-20 13:51:52 -05:00
UmputunandGitHub 14f267c89e Delete FUNDING.yml 2019-09-12 17:44:12 -05:00
UmputunandGitHub 44021b4dac Update FUNDING.yml 2019-09-12 17:43:43 -05:00
UmputunandGitHub 8eb00d94ad Create FUNDING.yml 2019-09-12 17:42:41 -05:00
Umputun e018fac6c4 revendor 2019-09-05 18:56:43 -05:00
Umputun 7971ebe6f4 ignore deleted comment for activity sorting 2019-09-03 16:39:14 -05:00
Umputun cdc1ae1e85 fix dev mode test 2019-09-01 13:29:34 -05:00
Umputun 9c62453869 fix anon test failed due to email added 2019-09-01 13:24:31 -05:00
Umputun 44ff590183 reword template file description 2019-09-01 13:21:05 -05:00
Umputun 1300464d2d Merge branch 'master' of github.com:umputun/remark 2019-08-27 03:33:43 -05:00
Umputun 22e5c25668 add custom email auth template 2019-08-27 03:33:34 -05:00
Umputun 074edc25ed fix unstable auth hook test 2019-08-27 03:12:11 -05:00
Umputun 8b6452f938 missing delete event in edit request 2019-08-26 22:36:52 -05:00
Umputun 68235164ff add OnEvent event to admin on each update/create/vote/delete 2019-08-26 20:53:04 -05:00
Umputun e9414f7267 fix comment typo 2019-08-26 00:59:26 -05:00
Umputun 106c018ef1 mem store example updated with adm.Enabled support 2019-08-26 00:58:27 -05:00
UmputunandGitHub 3c321f8992 Verified site id (#425)
* WIP: start with aud verification

* adjust rest test for token's site_id remark42

* add tests for non-matching aud

* fix auth hook test with updates limiter

* check siteID with enabled call for static store

* fix site enabled check
2019-08-25 22:00:19 -05:00
Umputun ef7476d3e1 change auth lib to 0.8.2, fixed user's aud promotion 2019-08-24 14:17:35 -05:00
Umputun a6149ae064 add support for avatar store uri 2019-08-24 01:59:24 -05:00
Umputun 141c75401a switch to latest baseimage allowing custom TZ and UID 2019-08-23 17:01:09 -05:00
Umputun 7fbf2cde61 add twitter auth #70 2019-08-23 03:17:31 -05:00
Umputun c5b927904f extend User struct with site_id/aud
updated to 0.8.0 of auth pkgs supporting user-space aud
2019-08-23 03:12:08 -05:00
Umputun 8d109901b8 Merge branch 'master' of github.com:umputun/remark 2019-08-20 00:07:19 -05:00
Umputun 07b16c46dc unescape quotes from user name #415 2019-08-20 00:07:13 -05:00
UmputunandGitHub 2e90b6172b Votes ip (#416)
* change vote params to request

* limit voting for the same ip

* limit same ip vote duration

* add same ip vote check for directions

* wire RestrictVoteIP and duration

* add votes-ip and votes-ip-time description
2019-08-19 14:04:12 -05:00
Umputun 9a5684de71 rename Blocked to better named BlockedUsers 2019-08-19 01:37:56 -05:00
Vyrtsev MikhailandUmputun d6d0109e80 change default post url from window.location.href to window.location.origin + window.location.pathname 2019-08-16 16:33:04 -05:00
Ivan BessarabovandUmputun f77ad01fbf Fixing indent in README.md 2019-08-11 16:01:35 -05:00
UmputunandGitHub 3ec9d0d8b2 Merge pull request #407 from elnardu/patch-1
Small README.md fix
2019-08-05 13:58:05 -05:00
Elnard UtiushevandGitHub d9e08b65a4 Small fix
The table mentions `positive-score` twice (lines 165 and 167)
2019-08-06 00:56:26 +06:00
Umputun 1c6c9d6dcf increase timeouts in unstable tests 2019-08-05 01:28:14 -05:00
UmputunandGitHub ae1f00f3a4 Merge pull request #402 from johnpuddephatt/master
Allow ID of container node to optionally be provided in comments widget config
2019-08-04 11:35:23 -05:00
UmputunandGitHub 0d4ee15df4 Merge pull request #403 from ShoshinNikita/master
Small fix of README.md
2019-08-04 11:35:11 -05:00
UmputunandGitHub 79a1cfca91 Merge pull request #406 from Reeywhaar/master
Fixes
2019-08-04 11:23:46 -05:00
Vyrtsev Mikhail a51b00760a fix: Comment reply/edit box resets if it gets out of view 2019-08-04 18:11:32 +03:00
Vyrtsev Mikhail 437c805e96 change scss import order (after component) 2019-08-04 17:40:02 +03:00
Vyrtsev Mikhail e92cce20c0 replace map with WeakMap 2019-08-04 17:22:41 +03:00
Shoshin NikitaandGitHub c5b62fa34e Small fix of README.md
Fix description of "image.type"
2019-08-02 00:57:35 +03:00
John Puddephatt 33c235ed3b remove stray space 2019-08-01 20:39:54 +01:00
John Puddephatt 95168041b9 allow ID of container node to optionally be provided in remark_config 2019-08-01 20:08:06 +01:00
UmputunandGitHub f0750c2433 Merge pull request #401 from umputun/rpc-example
RPC example
2019-08-01 02:08:26 -05:00
Umputun 5b173a05df add dockerfile and compose 2019-08-01 01:49:58 -05:00
Umputun 796c4f45eb fix rpc deps 2019-08-01 01:49:38 -05:00
Umputun 17baa0cff6 fix admin.admins call 2019-08-01 01:49:09 -05:00
Umputun da671c005c prep compose and Dockerfile 2019-07-31 23:24:48 -05:00
Umputun 3d883ba4bb clean rpc leftovers 2019-07-31 22:49:50 -05:00
Umputun 9ab18dfe1a fix zero time check 2019-07-31 18:47:28 -05:00
Umputun 3c21cba9c1 switch core to go-pkgz/jrpc 2019-07-31 18:38:53 -05:00
Umputun e84a155ef5 switch example to go-pkgz/jrpc 2019-07-31 18:34:32 -05:00
Umputun fcf175c905 Merge branch 'master' into rpc-example 2019-07-30 00:44:14 -05:00
Christian OliffandUmputun 159f4d4faf Fix GitHub capitalization 2019-07-29 13:48:32 -05:00
Vyrtsev MikhailandUmputun b6135510ab fix lint warnings 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 998e6a9a27 update demo page to look better on mobile 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 1aaad59afb add intersection-observer polyfill 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun cbf6abb201 make webpack exclude work 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 13e4f78762 add es5 compatibility check on post build 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 0f05b5097a rename action 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun cb1b42a2e0 render Comment with InView 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun a36733a2e3 add InView component which tracks element intersection with view 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun b4f083a051 remove redundant mutation 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 0f78bbf918 update package-lock.json after rebase 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 49a16f9071 clear interval on component unmount 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 8d748b6c65 reorganize comments reducers 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 48de9e15a3 add shallow compare util 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun e0ba7564f1 add bench util 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun a9e4ce368d add cmpRef util 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 0d6ae9edd4 add lodash 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun f34ab737d8 remove unused action 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 734eb0ebf3 replace duplicating babel config with common excludes getter 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 1d01a21480 replace require with import 2019-07-29 13:47:35 -05:00
Vyrtsev MikhailandUmputun 7fd3c563c7 add focus style to sort select 2019-07-26 18:02:11 -05:00
Vyrtsev MikhailandUmputun 82d1969393 remove unused test renderer 2019-07-26 18:02:11 -05:00
Vyrtsev MikhailandUmputun e1e1fdf5cf Comment test: replace inhouse test renderer with enzyme 2019-07-26 18:02:11 -05:00
Vyrtsev MikhailandUmputun a17204f85f Preloader test: replace inhouse test renderer with enzyme 2019-07-26 18:02:11 -05:00
Vyrtsev MikhailandUmputun 9da826219f AuthPanel test: replace inhouse test renderer with enzyme 2019-07-26 18:02:11 -05:00
Vyrtsev MikhailandUmputun 187674b54e remove duplicate checks in test 2019-07-26 18:02:11 -05:00
Vyrtsev MikhailandUmputun 0493ece73e fix auth panel providers sorting 2019-07-26 15:54:39 -05:00
Vyrtsev MikhailandUmputun b69cd08bf0 fix auth panel columns vertical alignment 2019-07-26 15:18:12 -05:00
Umputun f4bc6555dd Merge branch 'master' of github.com:umputun/remark 2019-07-26 12:53:18 -05:00
Umputun 2bf4ad80da add multiple auth methods to dev compose 2019-07-26 12:53:07 -05:00
Umputun 1ed0139304 fix screenshots image link 2019-07-26 11:12:20 -05:00
Umputun 86cefec13b add link to demo and screenshots to readme 2019-07-26 11:11:29 -05:00
Umputun 2d92266b60 add delete comment screenshot 2019-07-26 10:52:17 -05:00
Umputun 8b9a00ee0c add screenshots 2019-07-26 10:42:20 -05:00
Misha VyrtsevandUmputun 3b4628d0bd Auth login design (#390)
* break auth panel render into submethods

* move var definition

* break renderUnathorized into submethods

* hide login providers behind dropdown if they are exceed length of 3

* amend dropdown to behave nicely being placed in another dropdown

* add style to providers enclosed in dropdown

* add provider reducer and actions

* add provider save/restore to app flow

* place last login provider first in providers list

* infer StoreState from combineReducers return type

* move collapsed threads retoration to action

* fix: provider lost in other

* add dynamic threshold depending on window width

* fix & add tests
2019-07-25 22:54:59 -05:00
UmputunandGitHub eb3589869d Find since (#388)
* support since param in bolt find for post
* add find with since to store service
* add since param to find request, plain only
2019-07-25 13:58:22 -05:00
Misha VyrtsevandUmputun 5431f80e84 fix unblocking after page refresh stalled (#384) 2019-07-23 16:45:52 -05:00
Misha VyrtsevandUmputun 4d7699ec5e Fix blocking (#381)
* fix blocked user appears unblocked after refresh

* modify comment rendering to temporary blocking
2019-07-22 19:05:02 -05:00
Misha VyrtsevandUmputun 7103dbb589 fix sort been stalled (#379) 2019-07-22 16:06:53 -05:00
Umputun 240796b2d6 update doc with the current /config response document 2019-07-21 12:26:15 -05:00
Umputun ad3b7c695a expose emoji_enabled flag in /config response #378 2019-07-21 12:24:56 -05:00
Umputun 8759facb27 adjust alterComment test for #377 2019-07-21 11:35:45 -05:00
Umputun a60072564e temporary blocking doesn't need to delete/hide comments #377 2019-07-21 11:30:11 -05:00
Konstantin KrivleniaandUmputun 2f7c4e7e03 Add a button for image upload (#372)
* #371 add image icon for toolbar

* #371 add file upload handler

* #371 upload image from clipboard

* #371 change title and decrease size for upload button

* #371 allow upload few files

* #371 prevent paste text after file was uploaded in firefox
2019-07-16 10:27:44 -05:00
Vyrtsev MikhailandUmputun 17f61ee339 fix wrong className 2019-07-15 19:58:52 -05:00
Vyrtsev MikhailandUmputun 8cd5533aed fix lint errors 2019-07-15 17:57:43 -05:00
Vyrtsev MikhailandUmputun 9e726d5769 npm audit fix 2019-07-15 17:57:43 -05:00
Vyrtsev MikhailandUmputun 9863b2ddf0 update focus-visible 2019-07-15 17:57:43 -05:00
Vyrtsev MikhailandUmputun 461d8fbac5 update css-loader 2019-07-15 17:57:43 -05:00
Vyrtsev MikhailandUmputun d5e18d4290 update lint-staged 2019-07-15 17:57:43 -05:00
Vyrtsev MikhailandUmputun 343ea07d91 update husky 2019-07-15 17:57:43 -05:00
Vyrtsev MikhailandUmputun 387b6293ee update minor deps 2019-07-15 17:57:43 -05:00
Umputun d3a5151ab4 switch rss parent to blockquote only 2019-07-14 11:57:03 -05:00
Umputun a2aaf48c84 Merge branch 'master' of github.com:umputun/remark 2019-07-14 11:52:37 -05:00
Umputun 00cdfa2aa3 add missing help commands to readme 2019-07-14 11:52:28 -05:00
Umputun a4b24c1edb add blockquote inside summary, some rss readers seems to ignore summary 2019-07-14 00:15:26 -05:00
Umputun dfaf4d13d3 add parent summary to rss description #339 2019-07-14 00:00:02 -05:00
Umputun dda4f87db2 support optional emoji translation 2019-07-13 14:04:03 -05:00
Umputun 9585bdff34 lint: multiple warnings, mostly in tests 2019-07-13 13:40:41 -05:00
Andrey BlinovandUmputun accaae0bbd Change label in editing form from Edit to Save 2019-07-10 09:11:02 -05:00
Vyrtsev MikhailandUmputun e9e4b5476b change type of email field to email 2019-07-07 17:17:01 -05:00
Vyrtsev MikhailandUmputun 947ddd4da4 refine droppown content positioning 2019-07-07 17:17:01 -05:00
Vyrtsev MikhailandUmputun 8132819e1d add window expanding on dropdown open 2019-07-07 17:17:01 -05:00
Vyrtsev MikhailandUmputun 67cfc422b4 add onOpen/onClose props 2019-07-07 17:17:01 -05:00
Vyrtsev MikhailandUmputun cf84ad3cf5 unwrap if 2019-07-07 17:17:01 -05:00
Vyrtsev MikhailandUmputun 19efaf7e96 increase margins in email auth popup 2019-07-06 19:24:24 -05:00
Umputun 088294d36e switch to go-pkgz/auth v0.7.2 with limited verification token 2019-07-06 18:56:49 -05:00
Umputun cbbd8a7ae0 update email template 2019-07-06 18:54:28 -05:00
Umputun 820f3129fb integrate email template #366 2019-07-06 17:19:34 -05:00
Vyrtsev MikhailandUmputun fefd225b1a replace token input with textarea 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 6fd803e891 fix undefined ref 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 8ea5936ab0 reset email form on success 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 29322d920a add input focus on email auth form popup 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 18f41d21d7 add email auth ui test 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 3d62302bf0 add enzyme test renderer package 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 23691fb979 add jest-extended package 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 0ddb510e0e add constants mocking 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 3c2977f98c add redux-mock-store package 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun fedfe92781 add mockHeaders test util 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 81bf3ef35c add fetch-mock package 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun 84a3e6db7d add aggregateTimeout to webpack watch options 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun ac60bb8750 add email auth ui 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun c3253bec85 fix: DropDown doesn't remove listeners on unmount 2019-07-06 13:58:58 -05:00
Vyrtsev MikhailandUmputun c9ced7bd58 enable email auth in frontend dev compose 2019-07-06 13:58:58 -05:00
Umputun 52ecb53d31 resort imports 2019-07-06 13:36:34 -05:00
Umputun b2de952f38 move email auth before anon 2019-07-06 13:35:35 -05:00
Umputun f4335105c2 allow gravatars 2019-07-06 12:45:33 -05:00
Umputun ed3d0a314f Merge branch 'master' into email 2019-07-06 12:43:31 -05:00
Umputun dfbeb48a45 . 2019-07-04 20:21:57 -05:00
Umputun 1094d365a9 revendor with go-pkgz/auth v0.7.1 2019-07-02 22:42:26 -05:00
Umputun 24d685c4de add email notificator with auth sender 2019-07-02 22:02:36 -05:00
Umputun af67ec0fc8 revendor bf to v 2.0.1 2019-07-01 14:04:01 -05:00
Vyrtsev MikhailandUmputun e63ccdd18d fix: controls not visible on safari because of it doesn't have hover 2019-06-30 14:19:54 -05:00
Umputun b98d7ef1ef Merge remote-tracking branch 'origin/master' 2019-06-30 12:45:20 -05:00
Umputun 8ddfa56b82 add unescape table processing after md parsing #354 2019-06-30 12:45:15 -05:00
Vyrtsev MikhailandUmputun f70fa5f72e add spellcheck attribute 2019-06-30 12:31:39 -05:00
Umputun c224676ccd enable Smartypants for md rendering 2019-06-29 21:58:50 -05:00
Umputun 49593a3327 wip: memory rpc example 2019-06-29 21:17:34 -05:00
Vyrtsev MikhailandUmputun 90103a5fe5 make levels 5+ unindented 2019-06-29 21:00:31 -05:00
Vyrtsev MikhailandUmputun 75c294a692 move thread related styles to thread component 2019-06-29 21:00:31 -05:00
Vyrtsev MikhailandUmputun 12793c1110 make level5+ lines more dimmed 2019-06-29 21:00:31 -05:00
Vyrtsev MikhailandUmputun d7ec92ee39 make thread lines dotted 2019-06-29 21:00:31 -05:00
Vyrtsev MikhailandUmputun 1ac0421bb8 move selector for dark theme according to convent 2019-06-29 21:00:31 -05:00
Konstantin LandUmputun e2237e7396 show threads 2019-06-29 19:08:52 -05:00
Umputun b95179303b enforce empty [] response on flag's list instead of null #356 2019-06-29 12:01:55 -05:00
Vyrtsev MikhailandUmputun e5cc5e677b fix: settings widget fails because it expects list but gets null 2019-06-29 11:13:05 -05:00
Umputun 60d9f207ae only permanent blocking delete all comments 2019-06-28 11:58:09 -05:00
Umputun c09eaa6e00 rename remote to rpc 2019-06-27 14:29:46 -05:00
Umputun 63a555126e add test for app with remote plugins 2019-06-25 20:06:30 -05:00
Umputun 06bac880cb lint: more test warns 2019-06-25 20:06:30 -05:00
Umputun f6e69e86bb lint: test warnings 2019-06-25 20:06:30 -05:00
Umputun 5b79f7c435 clean drone build 2019-06-25 20:06:30 -05:00
Umputun 01bdf4d839 clear mongo leftovers 2019-06-25 20:06:30 -05:00
Umputun 82ce19fd3c larger limit for remote srv throttler 2019-06-25 20:06:30 -05:00
Umputun 348bede009 fix recreation of bdb in delete 2019-06-25 20:06:30 -05:00
Umputun 48fcb0a5d9 add remote selection to store and admin 2019-06-25 20:06:30 -05:00
Umputun 126d2d93da adjust remote tests, remove legacy mongo tests 2019-06-25 20:06:30 -05:00
Umputun db6c387fe9 support remote server group handler 2019-06-25 20:06:30 -05:00
Umputun f30937c55e fix empty (no args) remote calls 2019-06-25 20:06:30 -05:00
Umputun b30492556f add remote implementation of admin store 2019-06-25 20:06:30 -05:00
Umputun 988206bed4 change remote interface to struct requests 2019-06-25 20:06:30 -05:00
Umputun bab09effa2 error responses of json api with json, time out on remote server close 2019-06-25 20:06:30 -05:00
Umputun e307722901 switch list flag to struct param 2019-06-25 20:06:30 -05:00
Umputun f2cecafb27 fix remote client tests with id 2019-06-25 20:06:30 -05:00
Umputun 9f875394e8 remote server covered with tests 2019-06-25 20:06:30 -05:00
Umputun c1d14082ca implement DeleteUser soft mode to allow mapping for blocked users #341 2019-06-25 20:06:30 -05:00
Umputun 375e4a6dec initial ver of remote server 2019-06-25 20:06:30 -05:00
Umputun d8a2bb2f7b split remote to common protocol package and engine implemetation 2019-06-25 20:06:30 -05:00
Umputun c2b56eae30 remote client adjusted to new engine flavour 2019-06-25 20:06:30 -05:00
Umputun 3aba348e87 make new engine primary, rename package 2019-06-25 20:06:30 -05:00
Umputun 92d9b7703d restore all service coverage with new engine 2019-06-25 20:06:30 -05:00
Umputun 690c0df9b8 wip: engine2 passing test with updates service 2019-06-25 20:06:30 -05:00
Umputun 8d7c486e96 eliminate write timeout 2019-06-18 18:29:04 -05:00
Umputun 7819c9ec4b add keep-alive header to streamer 2019-06-18 18:14:58 -05:00
Dmitry VerkhoturovandUmputun f1b03b4bc6 fix typos, add missing configuration options to readme 2019-06-18 13:14:45 -05:00
Umputun 896f67ecdc add info and SSE streams and response example to readme 2019-06-18 12:58:50 -05:00
Umputun 64bc99fc8d fix incorrect default since for last comments stream 2019-06-18 12:38:51 -05:00
Umputun 445aec860e add support of "since" param (unix msec) #336 #253 2019-06-18 12:14:27 -05:00
Umputun e1bec310cf convert streams to SSE #336 2019-06-18 12:01:37 -05:00
Vyrtsev MikhailandUmputun dad51f052f fix sorting change makes hidden users to appear 2019-06-16 17:35:56 -05:00
Misha VyrtsevandUmputun 556b053ae7 Fix ui errors (preview and image upload) (#347)
* fix broken preview

* fix broken image upload
2019-06-16 16:02:13 -05:00
Misha VyrtsevandUmputun a302bdbe5d Hide user comments (#334)
* add ability to hide user via localstorage

* remove closures to avoid reconcilation

* add actionBinder utilities

* add user hide/show feature

* remove unused type

* fix css for settings user id

* remove pointless confirmation for hide/show user in settings

* add redux-dev-tools support
2019-06-10 19:20:39 -05:00
Umputun 133f5fc3f2 image removal from staging increased to 5*EditTTL
this is needed in case if user worked on the comment for long time
until submitting in order to avoid false-positive cleanup from the
staging
2019-06-08 21:54:00 -05:00
UmputunandGitHub 3a266111fa Merge pull request #338 from Reeywhaar/update-deps-2019-06
Update deps 2019 06
2019-06-05 16:33:59 -05:00
Vyrtsev Mikhail 9140e87152 fix lint-staged ignoring warnings 2019-06-05 23:36:24 +03:00
Vyrtsev Mikhail c8d39a9286 update frontend deps 2019-06-05 23:28:57 +03:00
Umputun 20764b7b4e enclose streamer in rest's struct 2019-06-05 02:14:34 -05:00
Umputun a8b2dee85c update deps 2019-06-04 23:32:23 -05:00
Umputun 342b4052a9 add error checks to pub rest test 2019-06-04 23:20:40 -05:00
Umputun fe46509e8e generalize stream api, move to separate struct & file 2019-06-04 22:06:33 -05:00
Umputun e1bd0e5104 eliminate first update on info stream on cold cache 2019-06-04 21:02:28 -05:00
Umputun 0f2298d193 externalize all stream params 2019-06-03 22:48:28 -05:00
Umputun f4d346c25a limit max number of active streams 2019-06-03 19:44:39 -05:00
Umputun 458eb27d8c add stream api for /last 2019-06-03 16:44:46 -05:00
Umputun d1cf9aecb3 make stream refresh slower, every 5s 2019-06-03 13:02:12 -05:00
Umputun 994a97b13f generalize event streams 2019-06-03 12:41:04 -05:00
UmputunandGitHub ca083f451e Info stream (#336)
* add /stream/info as a cheap way to subscribe to comment updates #253

* add check for lastTS change to allow proper info streams in no-cache mode

* check write error in info stream and terminate

* flaky info stream test

* add stream info to readme

* separate timeout middleware foe each route's group

* debug info on stream close

* fix test for streams

* stream timeout on inactivity only

* throttle streams to 500

* restore common throttle
2019-06-03 11:34:00 -05:00
Umputun bccf716a8d support view=user #323 2019-05-31 16:09:40 -05:00
Umputun bddb287e68 copy licence and readme to the curr directory #335 2019-05-26 22:03:27 -05:00
Umputun 2d38018722 remove commented old rss code 2019-05-19 13:24:54 -05:00
Umputun 350cca894d move reply retrieval to service, fix incorrect RSS title for reply 2019-05-19 12:56:47 -05:00
Umputun 6de89888c3 make controllers assignment explicit 2019-05-18 12:57:49 -05:00
Umputun 0142a65441 consolidate engine interfaces 2019-05-18 01:48:11 -05:00
Umputun e8e335d104 convert rss controllers to object 2019-05-17 22:05:53 -05:00
Umputun 255b9ad9c8 move config controller to rest 2019-05-17 21:50:23 -05:00
Umputun 2b8a2a082b move routes group setup to func 2019-05-17 21:50:06 -05:00
Umputun 6e12122e44 blocking also delete comments #332 2019-05-17 13:57:52 -05:00
UmputunandGitHub 7b8bc80468 Merge pull request #331 from umputun/alter
Alter
2019-05-17 02:50:32 -05:00
Umputun 00166fe57c add test to alter comment 2019-05-17 02:40:38 -05:00
Umputun cc37fbef13 switch admin rest to interface with subset of store api 2019-05-16 23:16:08 -05:00
Umputun 622a0bdacf move all private rest controllers to private struct, restrict store 2019-05-16 23:07:26 -05:00
Umputun a80c62517f wrap public rest into struct with safe/read-only interface as store 2019-05-16 21:41:25 -05:00
Umputun b9c80e64d0 add engine mock 2019-05-16 21:06:48 -05:00
Umputun 0590246112 move comment alter functionality from rest and admin to store service 2019-05-16 20:49:07 -05:00
UmputunandGitHub 5218f3f37c Support "since" parameter in "/last" query (#330)
* strip \n and spaces from retrieved title

* add "since" support to last storage and rest #329

* add info about since to readme
2019-05-16 13:58:37 -05:00
2470 changed files with 335061 additions and 143192 deletions
+2 -1
View File
@@ -20,4 +20,5 @@ debug.test
*.prof
*.test
remark42
/backend/var/
/backend/var/
compose-private-backend.yml
+63 -78
View File
@@ -1,101 +1,86 @@
workspace:
base: /go
path: src/github.com/umputun/remark
kind: pipeline
name: default
type: docker
pipeline:
build:
image: golang:1.12-alpine
steps:
- name: build server
image: umputun/baseimage:buildgo-latest
commands:
- sleep 5
- nslookup mongo
- nslookup mongo | grep Address | awk '{print $3}' > backend/.mongo
- cat backend/.mongo
- cd backend/app
- go build -v ./...
- go build -mod=vendor
- echo "build completed"
docker_master:
- name: docker master
image: plugins/docker
repo: umputun/remark42
secrets: [ docker_username, docker_password]
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
- DRONE_BRANCH=${DRONE_BRANCH}
tags:
- ${DRONE_COMMIT_BRANCH/\//-}
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, release/*]
branch: [master]
event: push
docker_tag:
- name: docker tag
image: plugins/docker
repo: umputun/remark42
secrets: [ docker_username, docker_password ]
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
tags:
- ${DRONE_TAG}
- latest
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
artifacts_tag:
- name: artifacts tag
image: plugins/docker
dockerfile: Dockerfile.artifacts
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
- GITHUB_TOKEN=${GITHUB_TOKEN}
settings:
dockerfile: Dockerfile.artifacts
build_args:
- DRONE=${DRONE}
- DRONE_TAG=${DRONE_TAG}
- DRONE_COMMIT=${DRONE_COMMIT}
- GITHUB_TOKEN=${GITHUB_TOKEN}
when:
event: tag
docker_branch:
image: plugins/docker
repo: umputun/remark42
secrets: [ docker_username, docker_password ]
build_args:
- DRONE=${DRONE}
- DRONE_COMMIT=${DRONE_COMMIT}
- DRONE_BRANCH=${DRONE_BRANCH}
tags:
- ${DRONE_COMMIT_BRANCH/\//-}
when:
branch:
exclude: [master, release/*]
event: push
deploy:
image: appleboy/drone-ssh
host: remark42.com
username: umputun
port: 22
secrets: [ ssh_key ]
script:
- cd /srv/remark
- docker-compose pull
- docker-compose up -d
- name: deploy
image: docker.umputun.com/system/deploy-ci:master
commands:
- ssh umputun@remark42.com "cd /srv/remark && docker-compose pull"
- ssh umputun@remark42.com "cd /srv/remark && docker-compose up -d"
- ssh umputun@remark42.com "cd /srv/remark-site && git pull && git submodule update --recursive --remote"
- ssh umputun@remark42.com "cd /srv/remark-site && docker-compose build && docker-compose up"
when:
branch: master
event: push
notify:
- name: notify
image: drillster/drone-email
host: smtp.mailgun.org
port: 25
username: email_username
password: email_password
from: drone@mg.umputun.com
recipients: [ umputun@gmail.com ]
secrets: [ email_username, email_password ]
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 ]
services:
mongo:
image: mongo:3.6
command: [ --smallfiles ]
+5
View File
@@ -0,0 +1,5 @@
# These owners will be the default owners for everything in the repo.
# Unless a later match takes precedence, @umputun will be requested for
# review when someone opens a pull request.
* @umputun
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [umputun]
patreon: remark42
+31
View File
@@ -0,0 +1,31 @@
name: build
on:
push:
branches:
tags:
paths:
- '.github/workflows/ci-build.yml'
- 'backend/**'
- 'frontend/**'
- '.dockerignore'
- 'docker-init.sh'
- 'Dockerfile'
pull_request:
paths:
- '.github/workflows/ci-build.yml'
- 'backend/**'
- 'frontend/**'
- '.dockerignore'
- 'docker-init.sh'
- 'Dockerfile'
jobs:
build:
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 .
@@ -0,0 +1,19 @@
name: frontend
on:
pull_request:
paths:
- '.github/workflows/ci-frontend-size-limit.yml'
- 'frontend/**'
jobs:
size:
runs-on: ubuntu-latest
env:
CI_JOB_NUMBER: 1
steps:
- uses: actions/checkout@v1
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
directory: frontend
+105
View File
@@ -0,0 +1,105 @@
name: frontend
on:
push:
branches:
tags:
paths:
- '.github/workflows/ci-frontend.yml'
- 'frontend/**'
pull_request:
paths:
- '.github/workflows/ci-frontend.yml'
- 'frontend/**'
jobs:
check-transtations:
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 }}
+63
View File
@@ -0,0 +1,63 @@
name: test_backend
on:
push:
branches:
tags:
paths:
- '.github/workflows/ci-test-backend.yml'
- 'backend/**'
- '!backend/scripts/**'
pull_request:
paths:
- '.github/workflows/ci-test-backend.yml'
- 'backend/**'
- '!backend/scripts/**'
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: debug if needed
run: if [[ "$DEBUG" == "true" ]]; then env; fi
env:
DEBUG: ${{secrets.DEBUG}}
- name: install go
uses: actions/setup-go@v1
with:
go-version: 1.14
- name: install golangci-lint and goveralls
run: |
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.26.0
go get -u github.com/mattn/goveralls
- name: test and lint backend
run: |
go test -race -timeout=60s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run --out-format=github-actions ./...
working-directory: backend/app
env:
GOFLAGS: "-mod=vendor"
TZ: "America/Chicago"
- name: test and lint examples
run: |
go version
$GITHUB_WORKSPACE/golangci-lint version
go test -race ./...
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run --out-format=github-actions ./...
working-directory: backend/_example/memory_store
env:
TZ: "America/Chicago"
- name: submit coverage
run: $(go env GOPATH)/bin/goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
working-directory: backend
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5
View File
@@ -19,3 +19,8 @@ debug.test
remark42
/bin/
/backend/var/
/backend/app/var/
compose-private-backend.yml
compose-private-frontend.yml
compose-private.yml
/backend/_example/*/vendor
-25
View File
@@ -1,25 +0,0 @@
install:
- docker --version
- docker-compose --version
script:
- docker run -d --name=mongo mongo:3.6 && sleep 3
- export MONGO_TEST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mongo)
- echo "running mongo on $MONGO_TEST"
- docker build
--build-arg COVERALLS_TOKEN=$COVERALLS_TOKEN
--build-arg CI=$CI
--build-arg TRAVIS=$TRAVIS
--build-arg TRAVIS_BRANCH=$TRAVIS_BRANCH
--build-arg TRAVIS_COMMIT=$TRAVIS_COMMIT
--build-arg TRAVIS_JOB_ID=$TRAVIS_JOB_ID
--build-arg TRAVIS_JOB_NUMBER=$TRAVIS_JOB_NUMBER
--build-arg TRAVIS_OS_NAME=$TRAVIS_OS_NAME
--build-arg TRAVIS_PULL_REQUEST=$TRAVIS_PULL_REQUEST
--build-arg TRAVIS_PULL_REQUEST_SHA=$TRAVIS_PULL_REQUEST_SHA
--build-arg TRAVIS_REPO_SLUG=$TRAVIS_REPO_SLUG
--build-arg TRAVIS_TAG=$TRAVIS_TAG
--build-arg MONGO_TEST=$MONGO_TEST
.
- docker rm -f mongo
+21 -45
View File
@@ -1,17 +1,6 @@
FROM umputun/baseimage:buildgo-latest as build-backend
FROM umputun/baseimage:buildgo-v1.6.1 as build-backend
ARG COVERALLS_TOKEN
ARG CI
ARG TRAVIS
ARG TRAVIS_BRANCH
ARG TRAVIS_COMMIT
ARG TRAVIS_JOB_ID
ARG TRAVIS_JOB_NUMBER
ARG TRAVIS_OS_NAME
ARG TRAVIS_PULL_REQUEST
ARG TRAVIS_PULL_REQUEST_SHA
ARG TRAVIS_REPO_SLUG
ARG TRAVIS_TAG
ARG DRONE
ARG DRONE_TAG
ARG DRONE_COMMIT
@@ -19,48 +8,34 @@ ARG DRONE_BRANCH
ARG DRONE_PULL_REQUEST
ARG SKIP_BACKEND_TEST
ARG MONGO_TEST
ARG BACKEND_TEST_TIMEOUT
ADD backend /build/backend
ADD .git /build/.git
ADD .git/ /build/backend/.git/
WORKDIR /build/backend
ENV GOFLAGS="-mod=vendor"
# install gcc in order to be able to go test package with -race
RUN apk --no-cache add gcc libc-dev
# run tests
RUN \
if [ -f .mongo ] ; then export MONGO_TEST=$(cat .mongo) ; fi && \
cd app && \
if [ -z "$SKIP_BACKEND_TEST" ] ; then \
go test -covermode=count -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 ; \
else echo "skip backend test" ; fi
RUN echo "mongo=${MONGO_TEST}" >> /etc/hosts
# linters
RUN if [ -z "$SKIP_BACKEND_TEST" ] ; then \
if [ -f .mongo ] ; then export MONGO_TEST=$(cat .mongo) ; fi && \
golangci-lint run --out-format=tab --disable-all --tests=false --enable=unconvert \
--enable=megacheck --enable=structcheck --enable=gas --enable=gocyclo --enable=dupl --enable=misspell \
--enable=unparam --enable=varcheck --enable=deadcode --enable=typecheck \
--enable=ineffassign --enable=varcheck ./... ; \
else echo "skip backend linters" ; fi
# submit coverage to coverals if COVERALLS_TOKEN in env
RUN if [ -z "$COVERALLS_TOKEN" ] ; then \
echo "coverall not enabled" ; \
else goveralls -coverprofile=/profile.cov -service=travis-ci -repotoken $COVERALLS_TOKEN || echo "coverall failed!"; fi
golangci-lint run --config ../.golangci.yml ./... ; \
else echo "skip backend tests and linter" ; fi
# if DRONE presented use DRONE_* git env to make version
RUN \
if [ -z "$DRONE" ] ; then echo "runs outside of drone" && version="local"; \
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S); fi && \
if [ -z "$DRONE" ] ; then echo "runs outside of drone" && version="$(/script/git-rev.sh)" ; \
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S) ; fi && \
echo "version=$version" && \
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
@@ -68,9 +43,9 @@ ENV HUSKY_SKIP_INSTALL=true
RUN apk add --no-cache --update git
ADD frontend/package.json /srv/frontend/package.json
ADD frontend/package-lock.json /srv/frontend/package-lock.json
RUN cd /srv/frontend && CI=true npm ci
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
@@ -79,22 +54,22 @@ ARG NODE_ENV=production
COPY --from=build-frontend-deps /srv/frontend/node_modules /srv/frontend/node_modules
ADD frontend /srv/frontend
RUN cd /srv/frontend && \
if [ -z "$SKIP_FRONTEND_TEST" ] ; then npx run-p lint test build ; \
if [ -z "$SKIP_FRONTEND_TEST" ] ; then npx run-p lint test check; \
else echo "skip frontend tests and lint" ; npm run build ; fi && \
rm -rf ./node_modules
FROM umputun/baseimage:app-latest
FROM umputun/baseimage:app-v1.6.1
WORKDIR /srv
ADD entrypoint.sh /entrypoint.sh
ADD docker-init.sh /entrypoint.sh
ADD backend/scripts/backup.sh /usr/local/bin/backup
ADD backend/scripts/restore.sh /usr/local/bin/restore
ADD backend/scripts/import.sh /usr/local/bin/import
RUN chmod +x /entrypoint.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
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
RUN chown -R app:app /srv
RUN ln -s /srv/remark42 /usr/bin/remark42
@@ -102,5 +77,6 @@ RUN ln -s /srv/remark42 /usr/bin/remark42
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost:8080/ping || exit 1
CMD ["server"]
ENTRYPOINT ["/entrypoint.sh"]
COPY docker-init.sh /srv/init.sh
RUN chmod +x /srv/init.sh
CMD ["/srv/remark42", "server"]
+49 -41
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
@@ -26,72 +26,80 @@ RUN cd /srv/frontend && \
npm run build && \
rm -rf ./node_modules
FROM umputun/baseimage:buildgo-latest as build-backend
ARG GITHUB_TOKEN
ENV SKIP_BACKEND_TEST=true
WORKDIR /go/src/github.com/umputun/remark/backend
ADD backend /go/src/github.com/umputun/remark/backend
ADD README.md /go/src/github.com/umputun/remark/
ADD LICENSE /go/src/github.com/umputun/remark/
WORKDIR /build/backend
ADD backend /build/backend
ADD README.md /build/
ADD LICENSE /build/
ADD .git/ /build/backend/.git/
COPY --from=build-frontend /srv/frontend/public/ web
RUN \
export WEB_ROOT=/go/src/github.com/umputun/remark/backend/web && \
sed -i "s|https://demo.remark42.com|http://127.0.0.1:8080|g" ${WEB_ROOT}/*.js && \
sed -i "/REMOVE-START/,/REMOVE-END/d" ${WEB_ROOT}/iframe.html && \
go get -v github.com/rakyll/statik && \
statik --src=${WEB_ROOT} --dest=/go/src/github.com/umputun/remark/backend/app/rest -p api -f && \
ls -la /go/src/github.com/umputun/remark/backend/app/rest/api/statik.go && \
ls -la /go/src/github.com/umputun/remark/backend/web/
export WEB_ROOT=/build/backend/web && \
find . -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \; && \
statik --src=${WEB_ROOT} --dest=/build/backend/app/rest -p api -f && \
statik --src=/build/backend/templates --dest=/build/backend/app -p templates -ns templates -f && \
ls -la /build/backend/app/templates/statik.go && \
ls -la /build/backend/app/rest/api/statik.go && \
ls -la /build/backend/web/
# if DRONE presented use DRONE_* git env to make version
RUN \
if [ -z "$DRONE" ] ; then \
echo "runs outside of drone" && version="local"; \
echo "runs outside of drone" && version=$(/script/git-rev.sh); \
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S); fi && \
echo "version=$version" && \
export GOFLAGS="-mod=vendor" && \
GOOS=linux GOARCH=amd64 go build -o remark42.linux-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=linux GOARCH=386 go build -o remark42.linux-386 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=linux GOARCH=arm go build -o remark42.linux-arm -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=linux GOARCH=arm64 go build -o remark42.linux-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=windows GOARCH=amd64 go build -o remark42.windows-amd64.exe -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=darwin GOARCH=amd64 go build -o remark42.darwin-amd64 -ldflags "-X main.revision=${version} -s -w" ./app
GOOS=darwin GOARCH=amd64 go build -o remark42.darwin-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
GOOS=freebsd GOARCH=amd64 go build -o remark42.freebsd-amd64 -ldflags "-X main.revision=${version} -s -w" ./app
RUN \
if [ -z "$DRONE_TAG" ] ; then \
echo "runs outside of drone" && tag=""; \
else tag=_${DRONE_TAG}; fi && \
apk add --no-cache --update zip && \
tar cvzf remark42${tag}.linux-amd64.tar.gz remark42.linux-amd64 ../LICENSE ../README.md && \
tar cvzf remark42${tag}.linux-386.tar.gz remark42.linux-386 ../LICENSE ../README.md && \
tar cvzf remark42${tag}.linux-arm64.tar.gz remark42.linux-arm64 ../LICENSE ../README.md && \
tar cvzf remark42${tag}.darwin-amd64.tar.gz remark42.darwin-amd64 ../LICENSE ../README.md && \
zip remark42${tag}.windows-amd64.zip remark42.windows-amd64.exe ../LICENSE ../README.md
cp ../LICENSE ./LICENSE && cp ../README.md ./README.md && \
tar cvzf remark42${tag}.linux-amd64.tar.gz remark42.linux-amd64 LICENSE README.md && \
tar cvzf remark42${tag}.linux-386.tar.gz remark42.linux-386 LICENSE README.md && \
tar cvzf remark42${tag}.linux-arm.tar.gz remark42.linux-arm LICENSE README.md && \
tar cvzf remark42${tag}.linux-arm64.tar.gz remark42.linux-arm64 LICENSE README.md && \
tar cvzf remark42${tag}.darwin-amd64.tar.gz remark42.darwin-amd64 LICENSE README.md && \
tar cvzf remark42${tag}.freebsd-amd64.tar.gz remark42.freebsd-amd64 LICENSE README.md && \
zip remark42${tag}.windows-amd64.zip remark42.windows-amd64.exe LICENSE README.md
# upload to github
RUN \
if [ -z "$DRONE_TAG" ] ; then \
echo "skip upload to github" ; \
else \
curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
-H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-amd64.tar.gz \
"https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-amd64.tar.gz" && \
curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
-H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-386.tar.gz \
"https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-386.tar.gz" && \
curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
-H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-arm64.tar.gz \
"https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-arm64.tar.gz" && \
curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
-H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.darwin-amd64.tar.gz \
"https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.darwin-amd64.tar.gz" && \
curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
-H "Content-Type: application/zip" --data-binary @remark42_${DRONE_TAG}.windows-amd64.zip \
"https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.windows-amd64.zip"; fi
#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 /go/src/github.com/umputun/remark/backend/remark42.* /artifacts/
COPY --from=build-backend /build/backend/remark42.* /artifacts/
RUN ls -la /artifacts/*
CMD ["sleep", "100"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 Umputun
Copyright (c) 2020 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
+17 -2
View File
@@ -11,7 +11,7 @@ bin:
docker:
docker build -t umputun/remark42 --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true .
deploy:
release:
docker build -f Dockerfile.artifacts -t remark42.bin .
- @docker rm -f remark42.bin 2>/dev/null || exit 0
- @mkdir -p bin
@@ -20,7 +20,22 @@ deploy:
docker cp remark42.bin:/artifacts/remark42.linux-386.tar.gz bin/remark42.linux-386.tar.gz
docker cp remark42.bin:/artifacts/remark42.linux-arm64.tar.gz bin/remark42.linux-arm64.tar.gz
docker cp remark42.bin:/artifacts/remark42.darwin-amd64.tar.gz bin/remark42.darwin-amd64.tar.gz
docker cp remark42.bin:/artifacts/remark42.freebsd-amd64.tar.gz bin/remark42.freebsd-amd64.tar.gz
docker cp remark42.bin:/artifacts/remark42.windows-amd64.zip bin/remark42.windows-amd64.zip
docker rm -f remark42.bin
.PHONY: bin
race_test:
cd backend/app && go test -race -mod=vendor -timeout=60s -count 1 ./...
backend:
docker-compose -f compose-dev-backend.yml build
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
+349 -150
View File
@@ -1,11 +1,15 @@
# remark42 [![Build Status](https://travis-ci.org/umputun/remark.svg?branch=master)](https://travis-ci.org/umputun/remark) [![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://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.
* Social login via Google, Facebook, Github and Yandex
* Social login via Google, Twitter, Facebook, Microsoft, GitHub and Yandex
* Login via email
* Optional anonymous access
* Multi-level nested comments with both tree and plain presentations
* Import from disqus and wordpress
* Import from Disqus and WordPress
* Markdown support with friendly formatter toolbar
* Moderator can remove comments and block users
* Voting, pinning and verification system
@@ -13,7 +17,8 @@ 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 notifications
* Telegram, Slack and email notifications for Admins (get notified for each new comment)
* Email notifications for users (get notified when someone responds to your comment)
* Export data to json with automatic backups
* No external databases, everything embedded in a single data file
* Fully dockerized and can be deployed in a single command
@@ -23,13 +28,23 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
* Integration with automatic ssl (direct and via [nginx-le](https://github.com/umputun/nginx-le))
* [Privacy focused](#privacy)
[Demo site](https://remark42.com/demo/) available with all authentication methods, including email auth and anonymous access.
<details><summary>Screenshots</summary>
Comments example:
![](https://github.com/umputun/remark42/blob/master/screenshots/comments.png)
For admin screenshots see [Admin UI wiki](https://github.com/umputun/remark42/wiki/Admin-UI)
</details>
#
- [Install](#install)
- [Backend](#backend)
- [With Docker](#with-docker)
- [Without docker](#without-docker)
- [Without Docker](#without-docker)
- [Parameters](#parameters)
- [Required parameters](#required-parameters)
- [Quick installation test](#quick-installation-test)
@@ -37,6 +52,7 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
- [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)
@@ -75,93 +91,147 @@ _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 docker hub and start - `docker-compose pull && docker-compose up -d`
* 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
#### Without Docker
* download archive for [stable release](https://github.com/umputun/remark/releases) or [development version](https://remark42.com/downloads)
* download archive for [stable release](https://github.com/umputun/remark42/releases) or [development version](https://remark42.com/downloads)
* unpack with `gunzip` (Linux, macOS) or with `zip` (Windows)
* run as `remark42.{os}-{arch} server {parameters...}`, i.e. `remark42.linux-amd64 server --secret=12345 --url=http://127.0.0.1:8080`
* alternatively compile from the sources - `make OS=[linux|darwin|windows] ARCH=[amd64,386,arm64,arm32]`
* 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 `mongo` |
| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory |
| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout |
| mongo.url | MONGO_URL | | mongo url for all stores using mongodb |
| mongo.db | MONGO_DB | | mongo database |
| 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 `mongo` |
| 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.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`, or `mongo` |
| 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.resize-width | IMAGE_RESIZE_WIDTH | `800` | width of resized image |
| image.resize-height | IMAGE_RESIZE_HEIGHT | `300` | 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.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 |
| notify.type | NOTIFY_TYPE | none | type of notification (none or telegram) |
| 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 |
| 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 |
| low-score | LOW_SCORE | `-5` | low score threshold |
| positive-score | POSITIVE_SCORE | `false` | enable positive score only |
| 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 |
| img-proxy | IMG_PROXY | `false` | enable http->https proxy for images |
| 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 | Environment | Default | Description |
| ----------------------- | ----------------------- | ------------------------ | ----------------------------------------------- |
| url | REMARK_URL | | url to remark42 server, _required_ |
| secret | SECRET | | secret key, _required_ |
| site | SITE | `remark` | site name(s), _multi_ |
| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `rpc` |
| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory |
| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout |
| admin.shared.id | ADMIN_SHARED_ID | | admin ids (list of user ids), _multi_ |
| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin emails, _multi_ |
| backup | BACKUP_PATH | `./var/backup` | backups location |
| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep |
| cache.type | CACHE_TYPE | `mem` | type of cache, `redis_pub_sub` or `mem` or `none` |
| cache.redis_addr | CACHE_REDIS_ADDR | `127.0.0.1:6379` | address of redis PubSub instance, turn `redis_pub_sub` cache on for distributed cache |
| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited |
| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited |
| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited |
| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, `bolt`, or `uri` |
| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store |
| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store |
| avatar.uri | AVATAR_URI | `./var/avatars` | avatar store uri |
| avatar.rsz-lmt | AVATAR_RSZ_LMT | `0` (disabled) | max image size for resizing avatars on save |
| image.type | IMAGE_TYPE | `fs` | type of image storage, `fs`, `bolt` |
| image.max-size | IMAGE_MAX_SIZE | `5000000` | max size of image file |
| image.fs.path | IMAGE_FS_PATH | `./var/pictures` | permanent location of images |
| image.fs.staging | IMAGE_FS_STAGING | `./var/pictures.staging` | staging location of images |
| image.fs.partitions | IMAGE_FS_PARTITIONS | `100` | number of image partitions |
| image.bolt.file | IMAGE_BOLT_FILE | `/var/pictures.db` | images bolt file location |
| image.resize-width | IMAGE_RESIZE_WIDTH | `2400` | width of resized image |
| image.resize-height | IMAGE_RESIZE_HEIGHT | `900` | height of resized image |
| auth.ttl.jwt | AUTH_TTL_JWT | `5m` | jwt TTL |
| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | cookie TTL |
| auth.send-jwt-header | AUTH_SEND_JWT_HEADER | `false` | send JWT as a header instead of cookie |
| auth.same-site | AUTH_SAME_SITE | `default` | set same site policy for cookies (`default`, `none`, `lax` or `strict`)|
| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID |
| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret |
| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID |
| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret |
| auth.microsoft.cid | AUTH_MICROSOFT_CID | | Microsoft OAuth client ID |
| auth.microsoft.csec | AUTH_MICROSOFT_CSEC | | Microsoft OAuth client secret |
| auth.github.cid | AUTH_GITHUB_CID | | GitHub OAuth client ID |
| auth.github.csec | AUTH_GITHUB_CSEC | | GitHub OAuth client secret |
| auth.twitter.cid | AUTH_TWITTER_CID | | Twitter Consumer API Key |
| auth.twitter.csec | AUTH_TWITTER_CSEC | | Twitter Consumer API Secret key |
| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID |
| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret |
| auth.dev | AUTH_DEV | `false` | local oauth2 server, development mode only |
| auth.anon | AUTH_ANON | `false` | enable anonymous login |
| auth.email.enable | AUTH_EMAIL_ENABLE | `false` | enable auth via email |
| auth.email.from | AUTH_EMAIL_FROM | | email from |
| auth.email.subj | AUTH_EMAIL_SUBJ | `remark42 confirmation` | email subject |
| auth.email.content-type | AUTH_EMAIL_CONTENT_TYPE | `text/html` | email content type |
| auth.email.template | AUTH_EMAIL_TEMPLATE | none (predefined) | custom email message template file |
| notify.type | NOTIFY_TYPE | none | type of notification (telegram, slack and/or email) |
| notify.queue | NOTIFY_QUEUE | `100` | size of notification queue |
| notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token |
| notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel |
| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout |
| notify.slack.token | NOTIFY_SLACK_TOKEN | | slack token |
| notify.slack.chan | NOTIFY_SLACK_CHAN | `general` | slack channel |
| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address |
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject |
| notify.email.notify_admin | NOTIFY_EMAIL_ADMIN | `false` | notify admin on new comments via ADMIN_SHARED_EMAIL |
| smtp.host | SMTP_HOST | | SMTP host |
| smtp.port | SMTP_PORT | | SMTP port |
| smtp.username | SMTP_USERNAME | | SMTP user name |
| smtp.password | SMTP_PASSWORD | | SMTP password |
| smtp.tls | SMTP_TLS | | enable TLS for SMTP |
| smtp.timeout | SMTP_TIMEOUT | `10s` | SMTP TCP connection timeout |
| ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le |
| ssl.port | SSL_PORT | `8443` | port for https server |
| ssl.cert | SSL_CERT | | path to cert.pem file |
| ssl.key | SSL_KEY | | path to key.pem file |
| ssl.acme-location | SSL_ACME_LOCATION | `./var/acme` | dir where obtained le-certs will be stored |
| ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE |
| max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit |
| max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited |
| votes-ip | VOTES_IP | `false` | restrict votes from the same ip |
| anon-vote | ANON_VOTE | `false` | allow voting for anonymous users, require VOTES_IP to be enabled as well |
| votes-ip-time | VOTES_IP_TIME | `5m` | same ip vote restriction time, `0s` - unlimited |
| low-score | LOW_SCORE | `-5` | low score threshold |
| critical-score | CRITICAL_SCORE | `-10` | critical score threshold |
| positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive |
| restricted-words | RESTRICTED_WORDS | | words banned in comments (can use `*`), _multi_ |
| restricted-names | RESTRICTED_NAMES | | names prohibited to use by the user, _multi_ |
| edit-time | EDIT_TIME | `5m` | edit window |
| read-age | READONLY_AGE | | read-only age of comments, days |
| image-proxy.http2https | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images |
| image-proxy.cache-external | IMAGE_PROXY_CACHE_EXTERNAL | `false` | enable caching external images to current image storage |
| emoji | EMOJI | `false` | enable emoji support |
| simple-view | SIMPLE_VIEW | `false` | minimized UI with basic info only |
| proxy-cors | PROXY_CORS | `false` | disable internal CORS and delegate it to proxy |
| allowed-hosts | ALLOWED_HOSTS | enable all | limit hosts/sources allowed to embed comments |
| port | REMARK_PORT | `8080` | web server port |
| web-root | REMARK_WEB_ROOT | `./web` | web server root directory |
| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit |
| admin-passwd | ADMIN_PASSWD | none (disabled) | password for `admin` basic auth |
| dbg | DEBUG | `false` | debug mode |
* command line parameters are long form `--<key>=value`, i.e. `--site=https://demo.remark42.com`
* _multi_ parameters separated by `,` in the environment or repeated with command line key, like `--site=s1 --site=s2 ...`
* _required_ parameters have to be presented in the environment or provided in command line
##### Deprecated
Following list of command-line options is deprecated and will be removed in 2 minor releases or 1 major release (whichever is closer)
from the version in which they were deprecated. After remark42 version update, please check startup log once for deprecation warnings to avoid
trouble with unrecognized command-line options in the future.
<details>
<summary>deprecated options</summary>
| Command line | Replacement | Environment | Replacement | Default | Description | Deprecation version |
| ------------------ | ------------- | ------------------ | ------------- | ------- | -------------- | ------------------- |
| auth.email.host | smtp.host | AUTH_EMAIL_HOST | SMTP_HOST | | smtp host | 1.5.0 |
| auth.email.port | smtp.port | AUTH_EMAIL_PORT | SMTP_PORT | | smtp port | 1.5.0 |
| auth.email.user | smtp.username | AUTH_EMAIL_USER | SMTP_USERNAME | | smtp user name | 1.5.0 |
| auth.email.passwd | smtp.password | AUTH_EMAIL_PASSWD | SMTP_PASSWORD | | smtp password | 1.5.0 |
| auth.email.tls | smtp.tls | AUTH_EMAIL_TLS | SMTP_TLS | `false` | enable TLS | 1.5.0 |
| auth.email.timeout | smtp.timeout | AUTH_EMAIL_TIMEOUT | SMTP_TIMEOUT | `10s` | smtp timeout | 1.5.0 |
| img-proxy | image-proxy.http2https | IMG_PROXY | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images | 1.5.0 |
</details>
##### Required parameters
Most of the parameters have sane defaults and don't require customization. There are only a few parameters user has to define:
1. `SECRET` - secret key, can be any long and hard-to-guess string.
2. `REMARK_URL` - url pointing to your remark42 server, i.e. `https://demo.reamark42.com`
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:
@@ -227,6 +297,22 @@ _instructions for google oauth2 setup borrowed from [oauth2_proxy](https://githu
1. Under **"Facebook login"** / **"Settings"** fill "Valid OAuth redirect URIs" with your callback url constructed as domain + `/auth/facebook/callback`
1. Select **"App Review"** and turn public flag on. This step may ask you to provide a link to your privacy policy.
#### Microsoft Auth Provider
1. Register a new application [using the Azure portal](https://docs.microsoft.com/en-us/graph/auth-register-app-v2).
2. Under **"Authentication/Platform configurations/Web"** enter the correct url constructed as domain + `/auth/microsoft/callback`. i.e. `https://example.mysite.com/auth/microsoft/callback`
3. In "Overview" take note of the **Application (client) ID**
4. Choose the new project from the top right project dropdown (only if another project is selected)
5. Select "Certificates & secrets" and click on "+ New Client Secret".
##### Twitter Auth Provider
1. Create a new twitter application https://developer.twitter.com/en/apps
1. Fill **App name**, **Description** and **URL** of your site
1. In the field **Callback URLs** enter the correct url of your callback handler e.g. domain + `/auth/twitter/callback`
1. Under **Key and tokens** take note of the **Consumer API Key** and **Consumer API Secret key**. Those will be used as `AUTH_TWITTER_CID` and
`AUTH_TWITTER_CSEC`
##### Yandex Auth Provider
1. Create a new **"OAuth App"**: https://oauth.yandex.com/client/new
@@ -247,11 +333,16 @@ Optionally, anonymous access can be turned on. In this case an extra `anonymous`
- name should be at least 3 characters long
- name has to start from the letter and contains letters, numbers, underscores and spaces only.
### Importing comments
Remark supports importing comments from Disqus, WordPress or native backup format.
All imported comments has `Imported` field set to `true`.
#### Initial import from Disqus
1. Disqus provides an export of all comments on your site in a g-zipped file. This is found in your Moderation panel at Disqus Admin > Setup > Export. The export will be sent into a queue and then emailed to the address associated with your account once it's ready. Direct link to export will be something like `https://<siteud>.disqus.com/admin/discussions/export/`. See [importing-exporting](https://help.disqus.com/customer/portal/articles/1104797-importing-exporting) for more details.
2. Move this file to your remark42 host within `./var` and unzip, i.e. `gunzip <disqus-export-name>.xml.gz`.
3. Run import command - `docker exec -it remark42 import -p disqus -f {disqus-export-name}.xml -s {your site id}`
3. Run import command - `docker exec -it remark42 import -p disqus -f /srv/var/{disqus-export-name}.xml -s {your site id}`
#### Initial import from WordPress
@@ -297,6 +388,39 @@ Admins/moderators should be defined in `docker-compose.yml` as a list of user ID
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
@@ -310,27 +434,42 @@ Add this snippet to the bottom of web page:
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.href will be used
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
page_title: 'Moving to Remark42', // optional param; if it isn't defined `document.title` will be used
locale: 'en', // set up locale and language, if it isn't defined default value ('en') will be used
show_email_subscription: false // optional param; by default it is `true` and you can see email subscription feature
// in interface when enable it from backend side
// if you set this param in `false` you will get notifications email notifications as admin
// but your users won't have interface for subscription
};
(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>
<script>!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),c=".js",d=n.head||n.body;"noModule"in r?(r.type="module",c=".mjs"):r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+c,d.appendChild(r)}}(remark_config.components||["embed"],document);</script>
```
And then add this node in the place where you want to see Remark42 widget:
@@ -341,6 +480,8 @@ And then add this node in the place where you want to see Remark42 widget:
After that widget will be rendered inside this node.
If you want to set this up on a Single Page App, see [appropriate doc page](https://remark42.com/docs/latest/spa/).
##### Themes
Right now Remark has two themes: light and dark.
@@ -354,6 +495,13 @@ Just call this function and pass a name of the theme that you want to turn on:
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.
@@ -367,15 +515,6 @@ Add this snippet to the bottom of web page, or adjust already present `remark_co
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>
```
@@ -400,15 +539,6 @@ Add this snippet to the bottom of web page, or adjust already present `remark_co
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>
```
@@ -422,27 +552,30 @@ 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 uses `url` property from `remark_config` object, or `window.location.href` if nothing else is defined.
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 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.
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
cp compose-dev-backend.yml compose-private.yml
# if you mainly work on frontend
docker-compose -f compose-dev-frontend.yml build
docker-compose -f compose-dev-frontend.yml up
cp compose-dev-frontend.yml compose-private.yml
# now, edit / debug `compose-private.yml` to your heart's content.
# build and run
docker-compose -f compose-private.yml build
docker-compose -f compose-private.yml up
```
It starts Remark42 on `127.0.0.1:8080` and adds local OAuth2 provider “Dev”.
@@ -450,32 +583,34 @@ 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 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 latest stable `go` toolchain [installed](https://golang.org/doc/install).
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 - `go run backend/app/main.go --dbg --secret=12345 --dev-passwd=password --site=remark --url=http://127.0.0.1:8080`
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://dev:password@127.0.0.1:8080/api/v1/find?site=remark&sort=-active&format=tree&url=http://127.0.0.1:8080`
To run backend with mongodb store mongo container should be started first - `docker run -d -p 27017:27017 -name=mongo mongo:3.6 --smallfiles` and then
`go run backend/app/main.go --dbg --secret=12345 --dev-passwd=password --site=remark --url=http://127.0.0.1:8080 --store.type=mongo --store.mongo.url=localhost`
`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
#### Build
#### Developer guide
* install [Node.js 8](https://nodejs.org/en/) or higher;
* install [NPM 6.1.0](https://www.npmjs.com/package/npm);
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` before every commit.
It used to reformat your frontend code using `prettier` and lint with `eslint` and `stylelint` before every commit.
#### Devserver
@@ -490,12 +625,20 @@ You can attach to locally running backend by providing `REMARK_URL` environment
npx cross-env REMARK_URL=http://127.0.0.1:8080 npm start
```
**Note** If you want to redefine env variables such as `PORT` on your local instance you can add `.env` file
to `./frontend` folder and rewrite variables as you wish. For such functional we use `dotenv`
The best way for start local developer environment:
```sh
cp compose-dev-frontend.yml compose-private-frontend.yml
docker-compose -f compose-private-frontend.yml up --build
cd frontend
npm run dev
```
Developer build running by `webpack-dev-server` supports devtools for [React](https://github.com/facebook/react-devtools) and
[Redux](https://github.com/zalmoxisus/redux-devtools-extension).
#### Frontend guide
Frontend guide can be found here: [./frontend/README.md](./frontend/README.md)
## API
@@ -581,7 +724,7 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i
}{}
```
* `GET /api/v1/last/{max}?site=site-id` - get up to `{max}` last comments
* `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
@@ -610,21 +753,58 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i
```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"`
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-ur` - returns `PostInfo` for site and url
* `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
@@ -639,6 +819,17 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i
_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`.
@@ -651,10 +842,17 @@ _returned id should be appended to load image url on caller side_
Until time.Time `json:"time"`
}
```
* `GET /api/v1/admin/export?site=side-id&mode=[stream|file]` - export all comments to json stream or gz file.
* `POST /api/v1/admin/import?site=side-id` - import comments from the backup, uses post body.
* `POST /api/v1/admin/import/form?site=side-id` - import comments from the backup, user post form.
* `GET /api/v1/admin/import/wait?site=side-id` - wait for import completeion.
* `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.
@@ -693,5 +891,6 @@ _all admin calls require auth and admin privilege_
* 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 (`IMG_PROXY=true`) to prevent mixed http/https.
* 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.
+18
View File
@@ -0,0 +1,18 @@
# 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.
+78
View File
@@ -0,0 +1,78 @@
run:
timeout: 5m
output:
format: tab
skip-dirs:
- vendor
linters-settings:
govet:
check-shadowing: true
golint:
min-confidence: 0.1
maligned:
suggest-new: true
goconst:
min-len: 2
min-occurrences: 2
misspell:
locale: US
lll:
line-length: 140
gocritic:
enabled-tags:
- performance
- style
- experimental
disabled-checks:
- wrapperFunc
# TODO: feel free to remove these excludes and fix the code
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain
linters:
enable:
- megacheck
- golint
- govet
- unconvert
- megacheck
- structcheck
- gas
- gocyclo
- dupl
- misspell
- unparam
- varcheck
- deadcode
- typecheck
- ineffassign
- varcheck
- stylecheck
- gochecknoinits
- scopelint
- gocritic
- nakedret
- gosimple
- prealloc
fast: false
disable-all: true
issues:
exclude-rules:
- text: "at least one file in a package should have a package comment"
linters:
- stylecheck
- text: "should have a package comment, unless it's in another file for this package"
linters:
- golint
- path: _test\.go
linters:
- gosec
- dupl
exclude-use-default: false
service:
golangci-lint-version: 1.31.x
+22
View File
@@ -0,0 +1,22 @@
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
RUN go build -o /build/bin/memory_store -ldflags "-X main.revision=0.0.0 -s -w"
FROM umputun/baseimage:app-latest
WORKDIR /srv
COPY --from=build-backend /build/bin/memory_store /srv/memory_store
RUN chown -R app:app /srv
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost:8080/ping || exit 1
USER app
CMD ["/srv/memory_store"]
+13
View File
@@ -0,0 +1,13 @@
# sample store implementation
`memory_store` illustrates how to make a custom storage plugin for remark42.
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run:
1. `docker-compose -f compose-dev-memstore.yml build`
1. `docker-compose -f compose-dev-memstore.yml up`
As usual, demo site will run on http://127.0.0.1:8080/web/
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package.
In real-life usage `replace github.com/umputun/remark42/backend => ../../` should not be used.
@@ -0,0 +1,87 @@
/*
* Copyright 2019 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store/admin"
)
// MemAdmin implements admin.Store with memory backend
type MemAdmin struct {
data map[string]AdminRec // admin info per site
key string
}
// AdminRec is a records per site with all admin info in
type AdminRec struct {
SiteID string
IDs []string // admin ids
Email string // admin email
Enabled bool // site enabled
CountCreated int64 // number of created posts
}
// NewMemAdminStore makes admin Store in memory
func NewMemAdminStore(key string) *MemAdmin {
log.Print("[DEBUG] make memory admin store")
return &MemAdmin{data: map[string]AdminRec{}, key: key}
}
// Key executes find by siteID and returns substructure with secret key
func (m *MemAdmin) Key(_ string) (key string, err error) {
return m.key, nil
}
// Admins executes find by siteID and returns admins ids
func (m *MemAdmin) Admins(siteID string) (ids []string, err error) {
resp, ok := m.data[siteID]
if !ok {
return nil, errors.Errorf("site %s not found", siteID)
}
log.Printf("[DEBUG] admins for %s, %+v", siteID, resp.IDs)
return resp.IDs, nil
}
// Email executes find by siteID and returns admin's email
func (m *MemAdmin) Email(siteID string) (email string, err error) {
resp, ok := m.data[siteID]
if !ok {
return "", errors.Errorf("site %s not found", siteID)
}
return resp.Email, nil
}
// Enabled return
func (m *MemAdmin) Enabled(siteID string) (ok bool, err error) {
resp, ok := m.data[siteID]
if !ok {
return false, errors.Errorf("site %s not found", siteID)
}
return resp.Enabled, nil
}
// OnEvent reacts on events from updates, created, delete and vote
func (m *MemAdmin) OnEvent(siteID string, ev admin.EventType) error {
resp, ok := m.data[siteID]
if !ok {
return errors.Errorf("site %s not found", siteID)
}
if ev == admin.EvCreate {
resp.CountCreated++ // not a good idea, just for demo
m.data[siteID] = resp
}
return nil
}
// Set admin data for siteID
func (m *MemAdmin) Set(siteID string, arec AdminRec) {
m.data[siteID] = arec
}
@@ -0,0 +1,73 @@
/*
* Copyright 2019 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark42/backend/app/store/admin"
)
func TestMemAdmin_Get(t *testing.T) {
adm := NewMemAdminStore("secret")
var ms admin.Store = adm
adm.data = map[string]AdminRec{
"site1": {"site1", []string{"i11", "i12"}, "e1", true, 0},
}
adm.Set("site2", AdminRec{"site2", []string{"i21", "i22"}, "e2", true, 0})
adm.Set("site3", AdminRec{"site3", []string{"i21", "i22"}, "e3", false, 0})
admins, err := ms.Admins("site1")
assert.NoError(t, err)
assert.Equal(t, []string{"i11", "i12"}, admins)
email, err := ms.Email("site1")
assert.NoError(t, err)
assert.Equal(t, "e1", email)
key, err := ms.Key("any")
assert.NoError(t, err)
assert.Equal(t, "secret", key)
admins, err = ms.Admins("site2")
assert.NoError(t, err)
assert.Equal(t, []string{"i21", "i22"}, admins)
email, err = ms.Email("site2")
assert.NoError(t, err)
assert.Equal(t, "e2", email)
key, err = ms.Key("any")
assert.NoError(t, err)
assert.Equal(t, "secret", key)
admins, err = ms.Admins("no-site-in-db")
assert.EqualError(t, err, "site no-site-in-db not found")
assert.Empty(t, admins)
email, err = ms.Email("no-site-in-db")
assert.EqualError(t, err, "site no-site-in-db not found")
assert.Empty(t, email)
enabled, err := ms.Enabled("site1")
assert.NoError(t, err)
assert.True(t, enabled)
enabled, err = ms.Enabled("site3")
assert.NoError(t, err)
assert.False(t, enabled)
enabled, err = ms.Enabled("no-site-in-db")
assert.EqualError(t, err, "site no-site-in-db not found")
assert.False(t, enabled)
err = ms.OnEvent("site1", admin.EvCreate)
assert.NoError(t, err)
err = ms.OnEvent("no-site-in-db", admin.EvCreate)
assert.Error(t, err)
}
@@ -0,0 +1,563 @@
/*
* Copyright 2019 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
"log"
"sort"
"sync"
"time"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
const lastLimit = 1000
// MemData implements in-memory data store
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
}
type metaPost struct {
PostURL string
SiteID string
ReadOnly bool
}
type metaUser struct {
UserID string
SiteID string
Verified bool
Blocked bool
BlockedUntil time.Time
Details engine.UserDetailEntry
}
// NewMemData makes in-memory engine.
func NewMemData() *MemData {
result := &MemData{
posts: map[string][]store.Comment{},
metaUsers: map[string]metaUser{},
metaPosts: map[store.Locator]metaPost{},
}
return result
}
// Create new comment
func (m *MemData) Create(comment store.Comment) (commentID string, err error) {
if ro, e := m.Flag(engine.FlagRequest{Flag: engine.ReadOnly, Locator: comment.Locator}); e == nil && ro {
return "", errors.Errorf("post %s is read-only", comment.Locator.URL)
}
m.Lock()
defer m.Unlock()
comments := m.posts[comment.Locator.SiteID]
for _, c := range comments { // don't allow duplicated IDs
if c.ID == comment.ID {
return "", errors.New("dup key")
}
}
comments = append(comments, comment)
m.posts[comment.Locator.SiteID] = comments
return comment.ID, nil
}
// 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()
comments = []store.Comment{}
if req.Sort == "" {
req.Sort = "time"
}
switch {
case req.Locator.SiteID != "" && req.Locator.URL != "": // find comments for site and url
comments = m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
return c.Locator == req.Locator && (req.Since.IsZero() || c.Timestamp.After(req.Since))
})
case req.Locator.SiteID != "" && req.Locator.URL == "" && req.UserID == "": // find last comments for site
if req.Limit > lastLimit || req.Limit == 0 {
req.Limit = lastLimit
}
if req.Since.IsZero() {
req.Since = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
}
comments = m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
return !c.Deleted && c.Timestamp.After(req.Since)
})
comments = engine.SortComments(comments, "-time")
if len(comments) > req.Limit {
comments = comments[:req.Limit]
}
return comments, nil
case req.Locator.SiteID != "" && req.UserID != "": // find comments for user
comments = m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
return c.User.ID == req.UserID
})
}
comments = engine.SortComments(comments, req.Sort)
if req.Skip > 0 && req.Skip > len(comments) {
return []store.Comment{}, nil
}
if req.Skip > 0 && req.Skip < len(comments) {
comments = comments[req.Skip:]
}
if req.Limit > 0 && req.Limit < len(comments) {
comments = comments[:req.Limit]
}
return comments, err
}
// 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()
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()
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()
switch {
case req.Locator.URL != "": // comment's count for post
comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
return c.Locator == req.Locator && !c.Deleted
})
return len(comments), nil
case req.UserID != "":
comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
return c.User.ID == req.UserID && !c.Deleted
})
return len(comments), nil
default:
return 0, errors.Errorf("invalid count request %+v", req)
}
}
// Info get post(s) meta info
func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error) {
m.RLock()
defer m.RUnlock()
res = []store.PostInfo{}
if req.Locator.URL != "" { // post info
comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
return c.Locator == req.Locator
})
if len(comments) == 0 {
return nil, errors.New("not found")
}
info := store.PostInfo{
URL: req.Locator.URL,
Count: len(comments),
ReadOnly: false,
FirstTS: comments[0].Timestamp.UTC(),
LastTS: comments[len(comments)-1].Timestamp.UTC(),
}
// set read-only from age and manual bucket
info.ReadOnly = req.ReadOnlyAge > 0 && !info.FirstTS.IsZero() &&
info.FirstTS.AddDate(0, 0, req.ReadOnlyAge).Before(time.Now())
if !info.ReadOnly {
v := m.checkFlag(engine.FlagRequest{Flag: engine.ReadOnly, Locator: req.Locator})
info.ReadOnly = v
}
return []store.PostInfo{info}, nil
}
if req.Locator.URL == "" && req.Locator.SiteID != "" { // site info (list)
if req.Limit <= 0 {
req.Limit = 1000
}
if req.Skip < 0 {
req.Skip = 0
}
infoAll := map[store.Locator]store.PostInfo{}
for _, c := range m.posts[req.Locator.SiteID] {
var info store.PostInfo
var ok bool
if info, ok = infoAll[c.Locator]; !ok {
info = store.PostInfo{URL: c.Locator.URL, FirstTS: c.Timestamp.UTC()}
}
info.Count++
info.LastTS = c.Timestamp.UTC()
infoAll[c.Locator] = info
}
for _, v := range infoAll {
res = append(res, v)
}
sort.Slice(res, func(i, j int) bool {
return res[i].URL > res[j].URL
})
if req.Skip > 0 {
if req.Skip >= len(res) {
return []store.PostInfo{}, nil
}
res = res[req.Skip:]
}
if req.Limit > 0 && req.Limit < len(res) {
res = res[:req.Limit]
}
return res, nil
}
return nil, errors.Errorf("invalid info request %+v", req)
}
// Flag sets and gets flag values
func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
m.Lock()
defer m.Unlock()
if req.Update == engine.FlagNonSet { // read flag value, no update requested
return m.checkFlag(req), nil
}
// write flag value
return m.setFlag(req)
}
// 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()
res = []interface{}{}
switch req.Flag {
case engine.Verified:
for _, u := range m.metaUsers {
if u.SiteID == req.Locator.SiteID {
res = append(res, u.UserID)
}
}
return res, nil
case engine.Blocked:
log.Printf("%+v", m.metaUsers)
for _, u := range m.metaUsers {
if u.SiteID == req.Locator.SiteID && u.Blocked && u.BlockedUntil.After(time.Now()) {
res = append(res, store.BlockedUser{ID: u.UserID, Until: u.BlockedUntil})
}
}
return res, nil
}
return nil, errors.Errorf("flag %s not listable", req.Flag)
}
// UserDetail sets or gets single detail value, or gets all details fo§r requested site.
// UserDetail returns list even for single entry request is a compromise in order to have both single detail getting and setting
// 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:
if req.UserID == "" {
return nil, errors.New("userid cannot be empty in request for single detail")
}
m.Lock()
defer m.Unlock()
if req.Update == "" { // read detail value, no update requested
return m.getUserDetail(req)
}
return m.setUserDetail(req)
case engine.AllUserDetails:
// list of all details returned in case request is a read request
// (Update is not set) and does not have UserID or Detail set
if req.Update == "" && req.UserID == "" { // read list of all details
m.Lock()
defer m.Unlock()
return m.listDetails(req.Locator)
}
return nil, errors.New("unsupported request with userdetail all")
default:
return nil, errors.Errorf("unsupported detail %q", req.Detail)
}
}
// Delete post(s), user, comment, user details, or everything
func (m *MemData) Delete(req engine.DeleteRequest) error {
m.Lock()
defer m.Unlock()
switch {
case req.UserDetail != "": // delete user detail
return m.deleteUserDetail(req.Locator, req.UserID, req.UserDetail)
case req.Locator.URL != "" && req.CommentID != "" && req.UserDetail == "": // delete comment
return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode)
case req.Locator.SiteID != "" && req.UserID != "" && req.CommentID == "" && req.UserDetail == "": // delete user
comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
return c.User.ID == req.UserID && !c.Deleted
})
for _, c := range comments {
if e := m.deleteComment(c.Locator, c.ID, req.DeleteMode); e != nil {
return e
}
}
return m.deleteUserDetail(req.Locator, req.UserID, engine.AllUserDetails)
case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "" && req.UserDetail == "": // delete site
if _, ok := m.posts[req.Locator.SiteID]; !ok {
return errors.New("not found")
}
m.posts[req.Locator.SiteID] = []store.Comment{}
return nil
}
return errors.Errorf("invalid delete request %+v", req)
}
func (m *MemData) deleteComment(loc store.Locator, id string, mode store.DeleteMode) error {
comments := m.match(m.posts[loc.SiteID], func(c store.Comment) bool {
return c.Locator == loc && c.ID == id
})
if len(comments) == 0 {
return errors.New("not found")
}
comments[0].SetDeleted(mode)
return m.updateComment(comments[0])
}
// Close store
func (m *MemData) Close() error {
return nil
}
func (m *MemData) checkFlag(req engine.FlagRequest) (val bool) {
switch req.Flag {
case engine.Blocked:
if meta, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID {
return false
}
return meta.Blocked && meta.BlockedUntil.After(time.Now())
}
case engine.Verified:
if meta, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID {
return false
}
return meta.Verified
}
case engine.ReadOnly:
if meta, ok := m.metaPosts[req.Locator]; ok {
return meta.ReadOnly
}
}
return false
}
func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
status := false
if req.Update == engine.FlagTrue {
status = true
}
switch req.Flag {
case engine.Blocked:
until := time.Time{}
if status {
until = time.Now().AddDate(100, 0, 0) // permanent is 100years
if req.TTL > 0 {
until = time.Now().Add(req.TTL)
}
}
meta := metaUser{
UserID: req.UserID,
SiteID: req.Locator.SiteID,
Blocked: status,
BlockedUntil: until,
}
m.metaUsers[req.UserID] = meta
case engine.Verified:
meta := metaUser{
UserID: req.UserID,
SiteID: req.Locator.SiteID,
Verified: status,
}
m.metaUsers[req.UserID] = meta
case engine.ReadOnly:
info, ok := m.metaPosts[req.Locator]
if !ok {
info.SiteID = req.Locator.SiteID
info.PostURL = req.Locator.URL
}
info.ReadOnly = status
m.metaPosts[req.Locator] = info
}
return status, errors.Wrapf(err, "failed to set flag %+v", req)
}
// getUserDetail returns UserDetailEntry with requested userDetail (omitting other details)
// as an only element of the slice.
func (m *MemData) getUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
if meta, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID {
return []engine.UserDetailEntry{}, nil
}
switch req.Detail {
case engine.UserEmail:
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}, nil
}
}
return []engine.UserDetailEntry{}, nil
}
// setUserDetail sets requested userDetail, returning complete updated UserDetailEntry as an onlyIps
// element of the slice in case of success
func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
var entry metaUser
if meta, ok := m.metaUsers[req.UserID]; ok {
if meta.SiteID != req.Locator.SiteID {
return []engine.UserDetailEntry{}, nil
}
entry = meta
}
if entry == (metaUser{}) {
entry = metaUser{
UserID: req.UserID,
SiteID: req.Locator.SiteID,
Details: engine.UserDetailEntry{UserID: req.UserID},
}
}
switch req.Detail {
case engine.UserEmail:
entry.Details.Email = req.Update
m.metaUsers[req.UserID] = entry
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}, nil
}
return []engine.UserDetailEntry{}, nil
}
// listDetails lists all available users details for given siteID
func (m *MemData) listDetails(loc store.Locator) ([]engine.UserDetailEntry, error) {
var res []engine.UserDetailEntry
for _, u := range m.metaUsers {
if u.SiteID == loc.SiteID {
res = append(res, u.Details)
}
}
return res, nil
}
// deleteUserDetail deletes requested UserDetail or whole UserDetailEntry,
// deletion of the absent entry doesn't produce error.
// Trying to delete user with wrong siteID doesn't to anything and doesn't produce error.
func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDetail engine.UserDetail) error {
var entry metaUser
if meta, ok := m.metaUsers[userID]; ok {
if meta.SiteID != locator.SiteID {
return nil
}
entry = meta
}
if entry == (metaUser{}) || entry.Details == (engine.UserDetailEntry{}) {
// absent entry means that we should not do anything
return nil
}
switch userDetail {
case engine.UserEmail:
entry.Details.Email = ""
case engine.AllUserDetails:
entry.Details = engine.UserDetailEntry{UserID: userID}
}
if entry.Details == (engine.UserDetailEntry{UserID: userID}) {
// no user details are stored, empty details entry altogether
entry.Details = engine.UserDetailEntry{}
}
m.metaUsers[userID] = entry
return nil
}
func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error) {
comments := m.match(m.posts[loc.SiteID], func(c store.Comment) bool {
return c.Locator == loc && c.ID == commentID
})
if len(comments) == 0 {
return store.Comment{}, errors.New("not found")
}
return comments[0], nil
}
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 {
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")
}
func (m *MemData) match(comments []store.Comment, fn func(c store.Comment) bool) (res []store.Comment) {
res = []store.Comment{}
for _, c := range comments {
if fn(c) {
res = append(res, c)
}
}
return res
}
@@ -0,0 +1,785 @@
/*
* Copyright 2019 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
"fmt"
"sort"
"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/engine"
)
func TestMemData_CreateAndFind(t *testing.T) {
m := prepMem(t) // adds two comments
req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
res, err := m.Find(req)
assert.NoError(t, err)
require.Equal(t, 2, len(res))
assert.Equal(t, `some text, <a href="http://radio-t.com">link</a>`, res[0].Text)
assert.Equal(t, "user1", res[0].User.ID)
_, err = m.Create(store.Comment{ID: res[0].ID, Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}})
require.Error(t, err)
assert.Contains(t, err.Error(), "dup key")
id, err := m.Create(store.Comment{ID: "id-3", Locator: store.Locator{URL: "https://radio-t2.com", SiteID: "radio-t2"}})
require.NoError(t, err)
assert.Equal(t, "id-3", id)
req = engine.FindRequest{Locator: store.Locator{URL: "https://radio-t2.com", SiteID: "radio-t2"}, Sort: "time"}
res, err = m.Find(req)
assert.NoError(t, err)
require.Equal(t, 1, len(res))
}
func TestMemData_CreateFailedReadOnly(t *testing.T) {
b := prepMem(t)
comment := store.Comment{
ID: "id-ro",
Text: `some text, <a href="http://radio-t.com">link</a>`,
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com/ro", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
flagReq := engine.FlagRequest{Locator: comment.Locator, Flag: engine.ReadOnly, Update: engine.FlagTrue}
v, err := b.Flag(flagReq)
require.NoError(t, err)
assert.Equal(t, true, v)
_, err = b.Create(comment)
assert.Error(t, err)
assert.Equal(t, "post https://radio-t.com/ro is read-only", err.Error())
flagReq = engine.FlagRequest{Locator: comment.Locator, Flag: engine.ReadOnly, Update: engine.FlagFalse}
v, err = b.Flag(flagReq)
require.NoError(t, err)
assert.Equal(t, false, v)
_, err = b.Create(comment)
assert.NoError(t, err)
}
func TestMemData_Get(t *testing.T) {
b := prepMem(t)
req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
res, err := b.Find(req)
assert.NoError(t, err)
require.Equal(t, 2, len(res), "2 records initially")
comment, err := b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[1].ID))
assert.NoError(t, err)
assert.Equal(t, "some text2", comment.Text)
_, err = b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "1234567"))
assert.EqualError(t, err, `not found`)
_, err = b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "bad"}, res[1].ID))
assert.EqualError(t, err, `not found`)
}
func TestMemData_Update(t *testing.T) {
b := prepMem(t)
req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
res, err := b.Find(req)
assert.NoError(t, err)
require.Equal(t, 2, len(res), "2 records initially")
comment := res[0]
comment.Text = "abc 123"
comment.Score = 100
err = b.Update(comment)
assert.NoError(t, err)
comment, err = b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID))
assert.NoError(t, err)
assert.Equal(t, "abc 123", comment.Text)
assert.Equal(t, res[0].ID, comment.ID)
assert.Equal(t, 100, comment.Score)
comment.Locator.SiteID = "bad"
err = b.Update(comment)
assert.EqualError(t, err, `not found`)
comment.Locator.SiteID = "https://radio-t.com"
comment.Locator.URL = "https://radio-t.com-bad"
err = b.Update(comment)
assert.EqualError(t, err, `not found`)
}
func TestMemData_FindLast(t *testing.T) {
b := prepMem(t)
req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time"}
res, err := b.Find(req)
assert.NoError(t, err)
require.Equal(t, 2, len(res))
assert.Equal(t, "some text2", res[0].Text)
req.Limit = 1
res, err = b.Find(req)
assert.NoError(t, err)
require.Equal(t, 1, len(res))
assert.Equal(t, "some text2", res[0].Text)
req.Locator.SiteID = "bad"
res, err = b.Find(req)
assert.NoError(t, err)
assert.Equal(t, 0, len(res))
}
func TestMemData_FindLastSince(t *testing.T) {
b := prepMem(t)
ts := time.Date(2017, 12, 20, 15, 18, 21, 0, time.Local)
req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", Since: ts}
res, err := b.Find(req)
assert.NoError(t, err)
require.Equal(t, 2, len(res))
assert.Equal(t, "some text2", res[0].Text)
req.Since = time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local)
res, err = b.Find(req)
assert.NoError(t, err)
require.Equal(t, 1, len(res))
assert.Equal(t, "some text2", res[0].Text)
req.Since = time.Date(2017, 12, 20, 16, 18, 22, 0, time.Local)
res, err = b.Find(req)
assert.NoError(t, err)
assert.Equal(t, 0, len(res))
}
func TestMemData_FindForUser(t *testing.T) {
b := prepMem(t)
req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1", Limit: 5}
res, err := b.Find(req)
assert.NoError(t, err)
require.Equal(t, 2, len(res))
assert.Equal(t, "some text2", res[0].Text, "sorted by -time")
req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1", Limit: 1}
res, err = b.Find(req)
assert.NoError(t, err)
require.Equal(t, 1, len(res), "allow 1 comment")
assert.Equal(t, "some text2", res[0].Text, "sorted by -time")
req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1", Limit: 1, Skip: 1}
res, err = b.Find(req)
assert.NoError(t, err)
require.Equal(t, 1, len(res), "allow 1 comment")
assert.Equal(t, `some text, <a href="http://radio-t.com">link</a>`, res[0].Text, "second comment")
req = engine.FindRequest{Locator: store.Locator{SiteID: "bad"}, Sort: "-time", UserID: "user1", Limit: 1, Skip: 1}
res, err = b.Find(req)
assert.NoError(t, err)
assert.Equal(t, 0, len(res), "no comments")
req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "userZ", Limit: 1, Skip: 1}
res, err = b.Find(req)
assert.NoError(t, err)
assert.Equal(t, 0, len(res), "no comments")
}
func TestMemData_FindForUserPagination(t *testing.T) {
b := NewMemData()
c := store.Comment{
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
// write 200 comments
for i := 0; i < 200; i++ {
c.ID = fmt.Sprintf("idd-%d", i)
c.Text = fmt.Sprintf("text #%d", i)
c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.Local)
_, err := b.Create(c)
require.NoError(t, err)
}
// get all comments
req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1"}
res, err := b.Find(req)
assert.NoError(t, err)
require.Equal(t, 200, len(res))
assert.Equal(t, "idd-199", res[0].ID)
// seek 0, 5 comments
req.Limit = 5
res, err = b.Find(req)
assert.NoError(t, err)
require.Equal(t, 5, len(res))
assert.Equal(t, "idd-199", res[0].ID)
assert.Equal(t, "idd-195", res[4].ID)
// seek 10, 3 comments
req.Skip, req.Limit = 10, 3
res, err = b.Find(req)
assert.NoError(t, err)
require.Equal(t, 3, len(res))
assert.Equal(t, "idd-189", res[0].ID)
assert.Equal(t, "idd-187", res[2].ID)
// seek 195, ask 10 comments
req.Skip, req.Limit = 195, 10
res, err = b.Find(req)
assert.NoError(t, err)
require.Equal(t, 5, len(res))
assert.Equal(t, "idd-4", res[0].ID)
assert.Equal(t, "idd-0", res[4].ID)
// seek 255, ask 10 comments
req.Skip, req.Limit = 255, 10
res, err = b.Find(req)
assert.NoError(t, err)
assert.Equal(t, 0, len(res))
}
func TestMemData_CountPost(t *testing.T) {
b := prepMem(t)
req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}}
c, err := b.Count(req)
assert.NoError(t, err)
require.Equal(t, 2, c)
req = engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com-xxx", SiteID: "radio-t"}}
c, err = b.Count(req)
assert.NoError(t, err)
assert.Equal(t, 0, c)
req = engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "bad"}}
c, err = b.Count(req)
assert.NoError(t, err)
assert.Equal(t, 0, c)
c, err = b.Count(engine.FindRequest{})
assert.Error(t, err)
assert.Equal(t, 0, c)
}
func TestMemData_CountUser(t *testing.T) {
b := prepMem(t)
req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"}
c, err := b.Count(req)
assert.NoError(t, err)
require.Equal(t, 2, c)
req = engine.FindRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1"}
c, err = b.Count(req)
assert.NoError(t, err)
assert.Equal(t, 0, c)
req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "userZ"}
c, err = b.Count(req)
assert.NoError(t, err)
assert.Equal(t, 0, c)
}
func TestMemData_InfoPost(t *testing.T) {
b := prepMem(t)
ts := func(min int) time.Time { return time.Date(2017, 12, 20, 15, 18, min, 0, time.Local).In(time.UTC) }
// add one more for https://radio-t.com/2
comment := store.Comment{
ID: "12345",
Text: `some text, <a href="http://radio-t.com">link</a>`,
Timestamp: time.Date(2017, 12, 20, 15, 18, 24, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
_, err := b.Create(comment)
assert.NoError(t, err)
req := engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, ReadOnlyAge: 0}
r, err := b.Info(req)
require.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(24), LastTS: ts(24)}}, r)
req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, ReadOnlyAge: 10}
r, err = b.Info(req)
require.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1,
FirstTS: ts(24), LastTS: ts(24), ReadOnly: true}}, r)
req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, ReadOnlyAge: 0}
r, err = b.Info(req)
require.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, r)
req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/error", SiteID: "radio-t"}, ReadOnlyAge: 0}
_, err = b.Info(req)
require.Error(t, err)
req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t-error"}, ReadOnlyAge: 0}
_, err = b.Info(req)
require.Error(t, err)
_, err = b.Info(engine.InfoRequest{})
require.Error(t, err)
fr := engine.FlagRequest{Flag: engine.ReadOnly,
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, Update: engine.FlagTrue}
_, err = b.Flag(fr)
require.NoError(t, err)
req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, ReadOnlyAge: 0}
r, err = b.Info(req)
require.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(24), LastTS: ts(24),
ReadOnly: true}}, r)
}
func TestMemData_InfoList(t *testing.T) {
b := prepMem(t)
// add one more for https://radio-t.com/2
comment := store.Comment{
ID: "12345",
Text: `some text, <a href="http://radio-t.com">link</a>`,
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
_, err := b.Create(comment)
assert.NoError(t, err)
ts := func(sec int) time.Time { return time.Date(2017, 12, 20, 15, 18, sec, 0, time.Local).In(time.UTC) }
req := engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}}
res, err := b.Info(req)
assert.NoError(t, err)
assert.EqualValues(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)},
{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
req = engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: -1, Skip: -1}
res, err = b.Info(req)
assert.NoError(t, err)
assert.EqualValues(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)},
{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
req = engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 1}
res, err = b.Info(req)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)}}, res)
req = engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 1, Skip: 1}
res, err = b.Info(req)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
req = engine.InfoRequest{Locator: store.Locator{SiteID: "bad"}, Limit: 1, Skip: 1}
res, err = b.Info(req)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{}, res)
}
func TestMemData_FlagBlockedUser(t *testing.T) {
b := prepMem(t)
req := engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"}
val, err := b.Flag(req)
assert.NoError(t, err)
assert.False(t, val, "nothing blocked yet")
req = engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
Update: engine.FlagTrue}
_, err = b.Flag(req)
assert.NoError(t, err)
val, err = b.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"})
assert.NoError(t, err)
assert.True(t, val, "user1 blocked")
req = engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
Update: engine.FlagTrue}
_, err = b.Flag(req)
assert.NoError(t, err)
val, err = b.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"})
assert.NoError(t, err)
assert.True(t, val, "user1 still blocked")
req = engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
Update: engine.FlagFalse}
_, err = b.Flag(req)
assert.NoError(t, err)
val, err = b.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"})
assert.NoError(t, err)
assert.False(t, val, "user1 unblocked")
}
func TestMemData_FlagReadOnlyPost(t *testing.T) {
b := prepMem(t)
req := engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly}
val, err := b.Flag(req)
assert.NoError(t, err)
assert.False(t, val, "nothing ro")
req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly,
Update: engine.FlagTrue}
val, err = b.Flag(req)
assert.NoError(t, err)
assert.True(t, val)
req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly}
val, err = b.Flag(req)
assert.NoError(t, err)
assert.True(t, val, "url-1 ro")
req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-2"}, Flag: engine.ReadOnly}
val, err = b.Flag(req)
assert.NoError(t, err)
assert.False(t, val, "url-2 still writable")
req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly,
Update: engine.FlagFalse}
_, err = b.Flag(req)
assert.NoError(t, err)
req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly}
val, err = b.Flag(req)
assert.NoError(t, err)
assert.False(t, val, "url-1 writable")
}
func TestMemData_FlagVerified(t *testing.T) {
b := prepMem(t)
isVerified := func(site, user string) bool {
req := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: site}, UserID: user}
v, err := b.Flag(req)
require.NoError(t, err)
return v
}
setVerified := func(site, user string, status engine.FlagStatus) error {
req := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: site}, UserID: user, Update: status}
_, err := b.Flag(req)
return err
}
assert.False(t, isVerified("radio-t", "u1"), "nothing verified")
assert.NoError(t, setVerified("radio-t", "u1", engine.FlagTrue))
assert.True(t, isVerified("radio-t", "u1"), "u1 verified")
assert.False(t, isVerified("radio-t", "u2"), "u2 still not verified")
assert.NoError(t, setVerified("radio-t", "u1", engine.FlagFalse))
assert.False(t, isVerified("radio-t", "u1"), "u1 not verified anymore")
assert.NoError(t, setVerified("bad", "u1", engine.FlagTrue))
assert.NoError(t, setVerified("radio-t", "u1xyz", engine.FlagFalse))
assert.False(t, isVerified("radio-t-bad", "u1"), "nothing verified on wrong site")
assert.NoError(t, setVerified("radio-t", "u1", engine.FlagTrue))
assert.NoError(t, setVerified("radio-t", "u2", engine.FlagTrue))
assert.NoError(t, setVerified("radio-t", "u3", engine.FlagFalse))
}
func TestMemData_FlagListVerified(t *testing.T) {
b := prepMem(t)
toIDs := func(inp []interface{}) (res []string) {
res = make([]string, len(inp))
for i, v := range inp {
vv, ok := v.(string)
require.True(t, ok)
res[i] = vv
}
sort.Strings(res)
return res
}
setVerified := func(site, user string, status engine.FlagStatus) error {
req := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: site}, UserID: user, Update: status}
_, err := b.Flag(req)
return err
}
ids, err := b.ListFlags(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
assert.Equal(t, []string{}, toIDs(ids), "verified list empty")
assert.NoError(t, setVerified("radio-t", "u1", engine.FlagTrue))
assert.NoError(t, setVerified("radio-t", "u2", engine.FlagTrue))
ids, err = b.ListFlags(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
assert.EqualValues(t, []string{"u1", "u2"}, toIDs(ids), "verified 2 ids")
ids, err = b.ListFlags(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: "radio-t-bad"}})
assert.NoError(t, err)
assert.Equal(t, 0, len(ids))
ids, err = b.ListFlags(engine.FlagRequest{})
assert.Error(t, err)
assert.Equal(t, 0, len(ids))
}
func TestMemData_FlagListBlocked(t *testing.T) {
b := prepMem(t)
setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
TTL: ttl}
_, err := b.Flag(req)
return err
}
toBlocked := func(inp []interface{}) (res []store.BlockedUser) {
res = make([]store.BlockedUser, len(inp))
for i, v := range inp {
vv, ok := v.(store.BlockedUser)
require.True(t, ok)
res[i] = vv
}
return res
}
assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
blockedList := toBlocked(vv)
var blockedIds = make([]string, len(blockedList))
for i, x := range blockedList {
blockedIds[i] = x.ID
}
require.Equal(t, 2, len(blockedList), b.metaUsers)
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIds)
t.Logf("%+v", blockedList)
// check block expiration
time.Sleep(50 * time.Millisecond)
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
assert.NoError(t, err)
blockedList = toBlocked(vv)
require.Equal(t, 1, len(blockedList))
assert.Equal(t, "user1", blockedList[0].ID)
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
assert.NoError(t, err)
assert.Equal(t, 0, len(vv))
}
func TestMemData_DeleteComment(t *testing.T) {
b := prepMem(t)
reqReq := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
res, err := b.Find(reqReq)
assert.NoError(t, err)
require.Equal(t, 2, len(res), "initially 2 comments")
count, err := b.Count(reqReq)
require.NoError(t, err)
require.Equal(t, 2, count, "count=2 initially")
delReq := engine.DeleteRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
CommentID: res[0].ID, DeleteMode: store.SoftDelete}
err = b.Delete(delReq)
assert.NoError(t, err)
res, err = b.Find(reqReq)
assert.NoError(t, err)
require.Equal(t, 2, len(res))
assert.Equal(t, "", res[0].Text)
assert.True(t, res[0].Deleted, "marked deleted")
assert.Equal(t, store.User{Name: "user name", ID: "user1", Picture: "", Admin: false, Blocked: false, IP: ""}, res[0].User)
assert.Equal(t, "some text2", res[1].Text)
assert.False(t, res[1].Deleted)
comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 10})
assert.NoError(t, err)
assert.Equal(t, 1, len(comments), "1 in last, 1 removed")
count, err = b.Count(reqReq)
require.NoError(t, err)
assert.Equal(t, 1, count)
delReq.CommentID = "123456"
err = b.Delete(delReq)
assert.Error(t, err)
delReq.Locator.SiteID = "bad"
delReq.CommentID = res[0].ID
err = b.Delete(delReq)
assert.EqualError(t, err, `not found`)
delReq.Locator = store.Locator{URL: "https://radio-t.com/bad", SiteID: "radio-t"}
err = b.Delete(delReq)
assert.EqualError(t, err, `not found`)
err = b.Delete(engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}})
assert.Error(t, err)
}
func TestMemData_Close(t *testing.T) {
b := prepMem(t)
assert.NoError(t, b.Close())
}
func TestMemData_DeleteHard(t *testing.T) {
b := prepMem(t)
reqReq := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
res, err := b.Find(reqReq)
assert.NoError(t, err)
assert.Equal(t, 2, len(res), "initially 2 comments")
delReq := engine.DeleteRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
CommentID: res[0].ID, DeleteMode: store.HardDelete}
err = b.Delete(delReq)
assert.NoError(t, err)
res, err = b.Find(reqReq)
assert.NoError(t, err)
require.Equal(t, 2, len(res))
assert.Equal(t, "", res[0].Text)
assert.True(t, res[0].Deleted, "marked deleted")
assert.Equal(t, store.User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, res[0].User)
}
func TestMemData_DeleteAll(t *testing.T) {
b := prepMem(t)
delReq := engine.DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}}
err := b.Delete(delReq)
assert.NoError(t, err)
comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 10})
assert.NoError(t, err)
assert.Equal(t, 0, len(comments), "nothing left")
}
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: ""}}
)
b := prepMem(t)
var testData = []struct {
delReq engine.DeleteRequest
detailReq engine.UserDetailRequest
expected []engine.UserDetailEntry
}{
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: createUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: readUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
detailReq: readUser, expected: emailUnset},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
detailReq: createUser, expected: emailSet},
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
detailReq: readUser, expected: emailUnset},
}
for i, x := range testData {
err := b.Delete(x.delReq)
require.NoError(t, err, "delete request #%d error", i)
val, err := b.UserDetail(x.detailReq)
require.NoError(t, err, "user request #%d error", i)
require.Equal(t, x.expected, val, "user request #%d result", i)
}
}
func TestMemAdmin_DeleteUserHard(t *testing.T) {
b := prepMem(t)
err := b.Delete(engine.DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
DeleteMode: store.HardDelete})
require.NoError(t, err)
comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"},
Sort: "time"})
assert.NoError(t, err)
require.Equal(t, 2, len(comments), "2 comments with deleted info")
assert.Equal(t, store.User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[0].User)
assert.Equal(t, store.User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[1].User)
c, err := b.Count(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}})
assert.NoError(t, err)
assert.Equal(t, 0, c, "0 count")
_, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1", Limit: 5})
assert.NoError(t, err, "no comments for user user1 in store")
comments, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "time"})
assert.NoError(t, err)
assert.Equal(t, 0, len(comments), "nothing left")
}
func TestMemAdmin_DeleteUserSoft(t *testing.T) {
b := prepMem(t)
err := b.Delete(engine.DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
DeleteMode: store.SoftDelete})
require.NoError(t, err)
comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"},
Sort: "time"})
assert.NoError(t, err)
require.Equal(t, 2, len(comments), "2 comments with deleted info")
assert.Equal(t, store.User{Name: "user name", ID: "user1", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[0].User)
assert.Equal(t, store.User{Name: "user name", ID: "user1", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[1].User)
c, err := b.Count(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}})
assert.NoError(t, err)
assert.Equal(t, 0, c, "0 count")
comments, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1", Limit: 5})
assert.NoError(t, err, "no comments for user user1 in store")
require.Equal(t, 2, len(comments), "2 comments with deleted info")
assert.True(t, comments[0].Deleted)
assert.True(t, comments[1].Deleted)
assert.Equal(t, "", comments[0].Text)
assert.Equal(t, "", comments[1].Text)
comments, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "time"})
assert.NoError(t, err)
assert.Equal(t, 0, len(comments), "nothing left")
}
func prepMem(t *testing.T) *MemData {
m := NewMemData()
comment := store.Comment{
ID: "id-1",
Text: `some text, <a href="http://radio-t.com">link</a>`,
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
_, err := m.Create(comment)
require.NoError(t, err)
comment = store.Comment{
ID: "id-2",
Text: "some text2",
Timestamp: time.Date(2017, 12, 20, 15, 18, 23, 0, time.Local),
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
User: store.User{ID: "user1", Name: "user name"},
}
_, err = m.Create(comment)
require.NoError(t, err)
return m
}
func getReq(locator store.Locator, commentID string) engine.GetRequest {
return engine.GetRequest{
Locator: locator,
CommentID: commentID,
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
"context"
"sync"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/remark42/backend/app/store/image"
)
// MemImage implements image.Store with memory backend
type MemImage struct {
imagesStaging map[string][]byte
images map[string][]byte
insertTime map[string]time.Time
sync.RWMutex
}
// NewMemImageStore makes admin Store in memory.
func NewMemImageStore() *MemImage {
log.Print("[DEBUG] make memory image store")
return &MemImage{
imagesStaging: map[string][]byte{},
images: map[string][]byte{},
insertTime: map[string]time.Time{},
}
}
// Save stores image with passed id to staging
func (m *MemImage) Save(id string, img []byte) error {
m.Lock()
m.imagesStaging[id] = img
m.insertTime[id] = time.Now()
m.Unlock()
return nil
}
// Load image by ID
func (m *MemImage) Load(id string) ([]byte, error) {
m.RLock()
img, ok := m.images[id]
if !ok {
img, ok = m.imagesStaging[id]
}
m.RUnlock()
if !ok {
return nil, errors.Errorf("image %s not found", id)
}
return img, nil
}
// Commit moves image from staging to permanent
func (m *MemImage) Commit(id string) error {
m.RLock()
img, ok := m.imagesStaging[id]
m.RUnlock()
if !ok {
return errors.Errorf("failed to commit %s, not found in staging", id)
}
m.Lock()
m.images[id] = img
m.Unlock()
return nil
}
// Cleanup runs removal loop for old images on staging
func (m *MemImage) Cleanup(_ context.Context, ttl time.Duration) error {
var idsToRemove []string
m.RLock()
for id, t := range m.insertTime {
age := time.Since(t)
if age > ttl {
log.Printf("[INFO] remove staging image %s, age %v", id, age)
idsToRemove = append(idsToRemove, id)
}
}
m.RUnlock()
m.Lock()
for _, id := range idsToRemove {
delete(m.insertTime, id)
delete(m.imagesStaging, id)
}
m.Unlock()
return nil
}
// Info returns meta information about storage
func (m *MemImage) Info() (image.StoreInfo, error) {
var ts time.Time
m.RLock()
for _, t := range m.insertTime {
if ts.IsZero() || t.Before(ts) {
ts = t
}
}
m.RUnlock()
return image.StoreInfo{FirstStagingImageTS: ts}, nil
}
@@ -0,0 +1,102 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
"context"
"encoding/base64"
"io"
"io/ioutil"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
"JkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk" +
"8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAA" +
"ILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36I" +
"MRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2" +
"MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZm" +
"rSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhkt" +
"rRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0" +
"dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEj" +
"yy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67" +
"LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR" +
"11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5fe" +
"A+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fU" +
"yLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy" +
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func TestMemImage_LoadAfterSave(t *testing.T) {
svc := NewMemImageStore()
gopher, err := ioutil.ReadAll(gopherPNG())
assert.NoError(t, err)
img, err := svc.Load("test_id")
assert.EqualError(t, err, "image test_id not found")
assert.Empty(t, img)
id := "test_img"
err = svc.Save(id, gopher)
assert.NoError(t, err)
img, err = svc.Load(id)
assert.NoError(t, err)
assert.Equal(t, gopher, img)
err = svc.Commit(id)
assert.NoError(t, err)
err = svc.Cleanup(context.TODO(), 0)
assert.NoError(t, err)
img, err = svc.Load(id)
assert.NoError(t, err)
assert.Equal(t, gopher, img)
}
func TestMemImage_CommitFail(t *testing.T) {
svc := NewMemImageStore()
err := svc.Commit("test_id")
assert.EqualError(t, err, "failed to commit test_id, not found in staging")
}
func TestMemImage_Cleanup(t *testing.T) {
svc := NewMemImageStore()
err := svc.Cleanup(context.TODO(), time.Minute)
assert.NoError(t, err)
}
func TestMemImage_Info(t *testing.T) {
svc := NewMemImageStore()
gopher, err := ioutil.ReadAll(gopherPNG())
assert.NoError(t, err)
// get info on empty storage, should be zero
info, err := svc.Info()
assert.NoError(t, err)
assert.True(t, info.FirstStagingImageTS.IsZero())
// save image
err = svc.Save("test_img", gopher)
assert.NoError(t, err)
// get info after saving, should be non-zero
info, err = svc.Info()
assert.NoError(t, err)
assert.False(t, info.FirstStagingImageTS.IsZero())
}
@@ -0,0 +1,65 @@
# 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'
services:
remark42:
build:
context: ../../..
dockerfile: Dockerfile
args:
- SKIP_BACKEND_TEST=true
- SKIP_FRONTEND_TEST=true
image: umputun/remark42:dev
container_name: "remark42-dev"
hostname: "remark42-dev"
restart: always
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
ports:
- "8080:8080" # primary rest server
- "8084:8084" # local oauth2 server
environment:
- REMARK_URL=http://127.0.0.1:8080
- SECRET=123456
- BACKUP_PATH=/srv/var/backup
- DEBUG=true
- EMOJI=true
- AUTH_ANON=true
- AUTH_DEV=true
- ADMIN_TYPE=rpc
- ADMIN_RPC_API=http://mem_store.r42:8080/cmd
- STORE_TYPE=rpc
- STORE_RPC_API=http://mem_store.r42:8080/cmd
- IMAGE_TYPE=rpc
- IMAGE_RPC_API=http://mem_store.r42:8080/cmd
volumes:
- ../../../var:/srv/var
mem_store.r42:
image: umputun/mem_store.r42
build:
context: ../../..
dockerfile: backend/_example/memory_store/Dockerfile
container_name: "mem_store.r42"
hostname: "mem_store.r42"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
environment:
- API=/cmd
- SECRET=123456
- DEBUG=true
+20
View File
@@ -0,0 +1,20 @@
module github.com/umputun/remark42/memory_store
go 1.14
require (
github.com/go-pkgz/auth v1.15.0 // indirect
github.com/go-pkgz/expirable-cache v0.0.3 // indirect
github.com/go-pkgz/jrpc v0.2.0
github.com/go-pkgz/lcw v0.8.1 // indirect
github.com/go-pkgz/lgr v0.10.4
github.com/go-pkgz/repeater v1.1.3 // indirect
github.com/go-pkgz/rest v1.9.2 // indirect
github.com/go-pkgz/syncs v1.1.1 // indirect
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.0
github.com/umputun/go-flags v1.5.1
github.com/umputun/remark42/backend v1.7.1
)
replace github.com/umputun/remark42/backend => ../../
+356
View File
@@ -0,0 +1,356 @@
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Depado/bfchroma v1.2.0 h1:NyYPFVhWvq8S2ts6Ok4kwXVE3TEO5fof+9ZOKbBJQUo=
github.com/Depado/bfchroma v1.2.0/go.mod h1:U3RJUYwWVJrZRaJQyfS+wuxBApSTR/BC37PhAI+Ydps=
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.6.0/go.mod h1:MmozekIi2rfQSzDcdEZ2BoJ9Pxs/7uc2Y4Boh+hIeZo=
github.com/alecthomas/chroma v0.7.2 h1:B76NU/zbQYIUhUowbi4fmvREmDUJLsUzKWTZmQd3ABY=
github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 h1:GDQdwm/gAcJcLAKQQZGOJ4knlw+7rfEQQcmwTbt4p5E=
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.11.4/go.mod h1:VL3UDEfAH59bSa7MuHMuFToxkqyHh69s/WUbYlOAuyg=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPIk=
github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0=
github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/didip/tollbooth/v6 v6.0.1 h1:QvLvRpB1G2bzKvkRze0muMUBlGN9H1z7tJ4DH4ypWOU=
github.com/didip/tollbooth/v6 v6.0.1/go.mod h1:j2pKs+JQ5PvU/K4jFnrnwntrmfUbYLJE5oSdxR37FD0=
github.com/didip/tollbooth/v6 v6.1.0 h1:ZS2fNa9JhFdRSJCj3+V12VfuUifYrGB4Z0jSwXmKMeE=
github.com/didip/tollbooth/v6 v6.1.0/go.mod h1:xjcse6CTHCLuOkzsWrEgdy9WPJFv+p/x6v+MyfP+O9s=
github.com/didip/tollbooth_chi v0.0.0-20200524181329-8b84cd7183d9 h1:gTh8fKuI/yLqQtZEPlDX3ZGsiTPZIe0ADHsxXSbwO1I=
github.com/didip/tollbooth_chi v0.0.0-20200524181329-8b84cd7183d9/go.mod h1:YWyIfq3y4ArRfWZ9XksmuusP+7Mad+T0iFZ0kv0XG/M=
github.com/didip/tollbooth_chi v0.0.0-20200828173446-a7173453ea21 h1:x7YpwKSBIBcKe9I3aTNOqgSyJ6QKDdtOxnEkxBTsi9w=
github.com/didip/tollbooth_chi v0.0.0-20200828173446-a7173453ea21/go.mod h1:0ZVa6kSzS011nfTC1rELyxK4tjVf6vqBnOv7oY2KlsA=
github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.0.2/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.1.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/cors v1.2.0/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
github.com/go-pkgz/auth v0.11.0/go.mod h1:NzVqlTW0E9JXVdAaWRq81XZjICgHnNaNdUfE3CbS2T4=
github.com/go-pkgz/auth v1.14.0/go.mod h1:1GVd61pXZcuJ0ZnOUdCTY08V8SreO7MJtsvEd5/WEWA=
github.com/go-pkgz/auth v1.15.0 h1:77z+YdcxHkRcjJSQc1SBtqIrZbp3p8Zo72ZRR3QKsLk=
github.com/go-pkgz/auth v1.15.0/go.mod h1:1HTdNEBMSFRCXoJLLjgCRs8t/gt2TQUaqf6wgYRvsTo=
github.com/go-pkgz/expirable-cache v0.0.3 h1:rTh6qNPp78z0bQE6HDhXBHUwqnV9i09Vm6dksJLXQDc=
github.com/go-pkgz/expirable-cache v0.0.3/go.mod h1:+IauqN00R2FqNRLCLA+X5YljQJrwB179PfiAoMPlTlQ=
github.com/go-pkgz/jrpc v0.2.0 h1:CLy/eZyekjraVrxZV18N2R1mYLMJ/nWrgdfyIOGPY/E=
github.com/go-pkgz/jrpc v0.2.0/go.mod h1:wd8vtQ4CgtCnuqua6x2b1SKIgv0VSOh5Dn0uUITbiUE=
github.com/go-pkgz/lcw v0.7.1/go.mod h1:3P6g9QrJsDePXEMe42ywO+tW08L17tBJGwIDdI7lZ6g=
github.com/go-pkgz/lcw v0.8.1 h1:Bpt2yYTE1J8hIhz8tjdm1WPOgH13eo5iTNsXyop7cMQ=
github.com/go-pkgz/lcw v0.8.1/go.mod h1:Xw0/ZfApATgbjVPYRZO4XHdWyxAjErDWDWJ7TLlw1Vc=
github.com/go-pkgz/lgr v0.7.0 h1:S/AAPwt/RE9a5mNJskA7dGVp+Dq6SMIW6LYjG3ITxY8=
github.com/go-pkgz/lgr v0.7.0/go.mod h1:yMgxU+GobMRJgIEbSzDKy/67W18S7qmGx/7BVL5AB8Q=
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw=
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0=
github.com/go-pkgz/repeater v1.1.3 h1:q6+JQF14ESSy28Dd7F+wRelY4F+41HJ0LEy/szNnMiE=
github.com/go-pkgz/repeater v1.1.3/go.mod h1:hVTavuO5x3Gxnu8zW7d6sQBfAneKV8X2FjU48kGfpKw=
github.com/go-pkgz/rest v1.5.0 h1:C8SxXcXza4GiUUAn/95iCkvoIrGbS30qpwK19iqlrWQ=
github.com/go-pkgz/rest v1.5.0/go.mod h1:nQaM3RhSTUAmbBZWY4hfe4buyeC9VckvhoCktiQXJxI=
github.com/go-pkgz/rest v1.6.0/go.mod h1:FKpgK5FgSqREG323OIU/JpIc0xA7dqay9BmK7LZXTQE=
github.com/go-pkgz/rest v1.9.2 h1:RyBBRXBYY6eBgTW3UGYOyT4VQPDiBBFh/tesELWsryQ=
github.com/go-pkgz/rest v1.9.2/go.mod h1:wZ/dGipZUaF9to0vIQl7PwDHgWQDB0jsrFg1xnAKLDw=
github.com/go-pkgz/syncs v1.1.1 h1:jWN+y6FS/Xe+8z4l3QMbSnODGyaxDHGojIS+wyKIjxg=
github.com/go-pkgz/syncs v1.1.1/go.mod h1:bt9lxWRRJ9vOCMGc8Big8ttjYHLKP88ofj1y38UlaHE=
github.com/go-redis/redis/v7 v7.2.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-redis/redis/v7 v7.4.0 h1:7obg6wUoj05T0EpY0o8B59S9w5yeMWql7sw2kwNW1x4=
github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0=
github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY=
github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg=
github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs=
github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI=
github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk=
github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28=
github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo=
github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk=
github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw=
github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360=
github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg=
github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE=
github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8=
github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4=
github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.7.1-0.20190322064113-39e2c31b7ca3/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.5 h1:U+CaK85mrNNb4k8BNOfgJtJ/gr6kswUCFj6miSzVC6M=
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kyokomi/emoji v2.2.1+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/microcosm-cc/bluemonday v1.0.9 h1:dpCwruVKoyrULicJwhuY76jB+nIxRVKv/e248Vx/BXg=
github.com/microcosm-cc/bluemonday v1.0.9/go.mod h1:B2riunDr9benLHghZB7hjIgdwSUzzs0pjCxFrWYEZFU=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4UMlGaDa1UTA0sfKgvF0hQZzTYX8ktjiDc=
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/slack-go/slack v0.8.2/go.mod h1:FGqNzJBmxIsZURAxh2a8D21AnOVvvXZvGligs4npPUM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.1.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
github.com/umputun/go-flags v1.5.1 h1:vRauoXV3Ultt1HrxivSxowbintgZLJE+EcBy5ta3/mY=
github.com/umputun/go-flags v1.5.1/go.mod h1:nTbvsO/hKqe7Utri/NoyN18GR3+EWf+9RrmsdwdhrEc=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc h1:n+nNi93yXLkJvKwXNP9d55HC7lGK4H/SRcwB5IaUZLo=
github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.mongodb.org/mongo-driver v1.3.2/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE=
go.mongodb.org/mongo-driver v1.4.4 h1:bsPHfODES+/yx2PCWzUYMH8xj6PVniPI8DQrsJuSXSs=
go.mongodb.org/mongo-driver v1.4.4/go.mod h1:WcMNYLx/IlOxLe6JRJiv2uXuCz6zBLndR4SoGjYphSc=
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 h1:eDrdRpKgkcCqKZQwyZRyeFZgfqt37SL7Kv3tok06cKE=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 h1:0PC75Fz/kyMGhL0e1QnypqK2kQMqKt9csD1GnMJR+Zk=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/oauth2.v3 v3.12.0 h1:yOffAPoolH/i2JxwmC+pgtnY3362iPahsDpLXfDFvNg=
gopkg.in/oauth2.v3 v3.12.0/go.mod h1:XEYgKqWX095YiPT+Aw5y3tCn+7/FMnlTFKrupgSiJ3I=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package main
import (
"fmt"
"os"
"github.com/go-pkgz/jrpc"
log "github.com/go-pkgz/lgr"
"github.com/umputun/go-flags"
"github.com/umputun/remark42/memory_store/accessor"
"github.com/umputun/remark42/memory_store/server"
)
// opts with all cli commands and flags
var opts struct {
API string `long:"api" env:"API" default:"/" description:"api root url"`
Port int `long:"port" env:"PORT" default:"8080" description:"rpc server port"`
AuthUser string `long:"auth-user" env:"AUTH_USER" default:"" description:"rpc auth user name"`
AuthPasswd string `long:"auth-passwd" env:"AUTH_PASSWD" default:"" description:"rpc auth password"`
Secret string `long:"secret" env:"SECRET" required:"true" description:"secret key"`
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
}
var revision = "unknown"
func main() {
fmt.Printf("remark42-memory module %s\n", revision)
if _, err := flags.Parse(&opts); err != nil {
os.Exit(2)
}
setupLog(opts.Dbg)
dataStore := accessor.NewMemData()
adminStore := accessor.NewMemAdminStore(opts.Secret)
imgStore := accessor.NewMemImageStore()
rpcServer := jrpc.Server{
API: opts.API,
AuthUser: opts.AuthUser,
AuthPasswd: opts.AuthPasswd,
Version: revision,
AppName: "remark42-memory",
Logger: log.Default(),
}
srv := server.NewRPC(dataStore, adminStore, imgStore, &rpcServer)
admRec := accessor.AdminRec{
SiteID: "remark",
Enabled: true,
IDs: []string{"dev_user"},
Email: "admin@example.com",
}
adminStore.Set("remark", admRec)
err := srv.Run(opts.Port)
log.Printf("[ERROR] server failed or terminated, %+v", err)
}
func setupLog(dbg bool) {
if dbg {
log.Setup(log.Debug, log.CallerFile, log.CallerFunc, log.Msec, log.LevelBraces)
return
}
log.Setup(log.Msec, log.LevelBraces)
}
@@ -0,0 +1,93 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"encoding/json"
"github.com/go-pkgz/jrpc"
"github.com/umputun/remark42/backend/app/store/admin"
)
// get admin 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()}
}
return jrpc.EncodeResponse(id, key, err)
}
// get admins list
func (s *RPC) admAdminsHndl(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()}
}
admins, err := s.adm.Admins(siteID)
if err != nil {
return jrpc.Response{Error: err.Error()}
}
return jrpc.EncodeResponse(id, admins, err)
}
// get admin email
func (s *RPC) admEmailHndl(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()}
}
email, err := s.adm.Email(siteID)
if err != nil {
return jrpc.Response{Error: err.Error()}
}
return jrpc.EncodeResponse(id, email, err)
}
// return site enabled status
func (s *RPC) admEnabledHndl(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()}
}
ok, err := s.adm.Enabled(siteID)
if err != nil {
return jrpc.Response{Error: err.Error()}
}
return jrpc.EncodeResponse(id, ok, err)
}
// onEvent returns nothing, callback to OnEvent
func (s *RPC) admEventHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var siteID string
var ps []interface{}
if err := json.Unmarshal(params, &ps); err != nil {
return jrpc.Response{Error: err.Error()}
}
siteID, ok := ps[0].(string)
if !ok {
return jrpc.Response{Error: "wrong siteID type"}
}
evType, ok := ps[1].(float64)
if !ok {
return jrpc.Response{Error: "wrong event type"}
}
err := s.adm.OnEvent(siteID, admin.EventType(evType))
if err != nil {
return jrpc.Response{Error: err.Error()}
}
return jrpc.EncodeResponse(id, nil, err)
}
@@ -0,0 +1,89 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark42/backend/app/store/admin"
)
func TestRPC_admKeyHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
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("any")
assert.NoError(t, err)
assert.Equal(t, "secret", key)
}
func TestRPC_admAdminsHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
_, err := ra.Admins("bad site")
assert.EqualError(t, err, "site bad site not found")
admins, err := ra.Admins("test-site")
assert.NoError(t, err)
assert.Equal(t, []string{"id1", "id2"}, admins)
}
func TestRPC_admEmailHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
_, err := ra.Admins("bad site")
assert.EqualError(t, err, "site bad site not found")
email, err := ra.Email("test-site")
assert.NoError(t, err)
assert.Equal(t, "admin@example.com", email)
}
func TestRPC_admEnabledHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
_, err := ra.Enabled("bad site")
assert.EqualError(t, err, "site bad site not found")
ok, err := ra.Enabled("test-site")
assert.NoError(t, err)
assert.Equal(t, true, ok)
ok, err = ra.Enabled("test-site-disabled")
assert.NoError(t, err)
assert.Equal(t, false, ok)
}
func TestRPC_admEventHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
err := ra.OnEvent("bad site", admin.EvCreate)
assert.EqualError(t, err, "site bad site not found")
err = ra.OnEvent("test-site", admin.EvCreate)
assert.NoError(t, err)
}
@@ -0,0 +1,125 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"encoding/json"
"github.com/go-pkgz/jrpc"
"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) {
comment := store.Comment{}
if err := json.Unmarshal(params, &comment); err != nil {
return jrpc.Response{Error: err.Error()}
}
commentID, err := s.eng.Create(comment)
return jrpc.EncodeResponse(id, commentID, err)
}
// Find comments
func (s *RPC) findHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.FindRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
resp, err := s.eng.Find(req)
return jrpc.EncodeResponse(id, resp, err)
}
// Get comment
func (s *RPC) getHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.GetRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
comment, err := s.eng.Get(req)
return jrpc.EncodeResponse(id, comment, err)
}
// Update comment
func (s *RPC) updateHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
comment := store.Comment{}
if err := json.Unmarshal(params, &comment); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.eng.Update(comment)
return jrpc.EncodeResponse(id, nil, err)
}
// counts for site and users
func (s *RPC) countHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.FindRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
count, err := s.eng.Count(req)
return jrpc.EncodeResponse(id, count, err)
}
// info get post meta info
func (s *RPC) infoHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.InfoRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
info, err := s.eng.Info(req)
return jrpc.EncodeResponse(id, info, err)
}
// flagHndl get and sets flag value
func (s *RPC) flagHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.FlagRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
status, err := s.eng.Flag(req)
return jrpc.EncodeResponse(id, status, err)
}
// listFlagsHndl list flags for given request
func (s *RPC) listFlagsHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.FlagRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
flags, err := s.eng.ListFlags(req)
return jrpc.EncodeResponse(id, flags, err)
}
// userDetailHndl sets or gets single detail value, or gets all details for requested site.
// userDetailHndl returns list even for single entry request is a compromise in order to have both single detail getting and setting
// and all site's details listing under the same function (and not to extend engine interface by two separate functions).
func (s *RPC) userDetailHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.UserDetailRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
value, err := s.eng.UserDetail(req)
return jrpc.EncodeResponse(id, value, err)
}
// deleteHndl delete post(s), user, comment, user details, or everything
func (s *RPC) deleteHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
req := engine.DeleteRequest{}
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.eng.Delete(req)
return jrpc.EncodeResponse(id, nil, err)
}
// close store
func (s *RPC) closeHndl(_ uint64, _ json.RawMessage) (rr jrpc.Response) {
if err := s.eng.Close(); err != nil {
return jrpc.Response{Error: err.Error()}
}
return jrpc.Response{}
}
@@ -0,0 +1,306 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/engine"
)
func TestRPC_createHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
id, err := re.Create(store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}})
assert.NoError(t, err)
assert.Equal(t, "123456", id)
}
func TestRPC_findHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
findReq := engine.FindRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
comments, err := re.Find(findReq)
require.NoError(t, err)
assert.Equal(t, 0, len(comments))
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
id, err := re.Create(c)
assert.NoError(t, err)
assert.Equal(t, "123456", id)
comments, err = re.Find(findReq)
require.NoError(t, err)
assert.Equal(t, 1, len(comments))
assert.Equal(t, c, comments[0])
}
func TestRPC_getHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
req := engine.GetRequest{
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
CommentID: "123456",
}
_, err := re.Get(req)
assert.EqualError(t, err, "not found")
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
_, err = re.Create(c)
assert.NoError(t, err)
comment, err := re.Get(req)
assert.NoError(t, err)
assert.Equal(t, c, comment)
}
func TestRPC_updateHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
err := re.Update(c)
assert.EqualError(t, err, "not found")
_, err = re.Create(c)
assert.NoError(t, err)
c.Text = "updates"
err = re.Update(c)
assert.NoError(t, err)
req := engine.GetRequest{
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
CommentID: "123456",
}
comment, err := re.Get(req)
assert.NoError(t, err)
assert.Equal(t, c, comment)
}
func TestRPC_countHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
findReq := engine.FindRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
count, err := re.Count(findReq)
require.NoError(t, err)
assert.Equal(t, 0, count)
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
id, err := re.Create(c)
assert.NoError(t, err)
assert.Equal(t, "123456", id)
count, err = re.Count(findReq)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
func TestRPC_infoHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
id, err := re.Create(c)
assert.NoError(t, err)
assert.Equal(t, "123456", id)
infoReq := engine.InfoRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
info, err := re.Info(infoReq)
require.NoError(t, err)
assert.Equal(t, 1, len(info))
i := info[0]
assert.Equal(t, store.PostInfo{URL: "http://example.com/post1", Count: 1}, i)
}
func TestRPC_flagHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
id, err := re.Create(c)
assert.NoError(t, err)
assert.Equal(t, "123456", id)
flagReq := engine.FlagRequest{
Flag: engine.Verified,
Locator: store.Locator{
SiteID: "test-site",
},
UserID: "u1",
}
status, err := re.Flag(flagReq)
require.NoError(t, err)
assert.Equal(t, false, status)
flagReq.Update = engine.FlagTrue
status, err = re.Flag(flagReq)
require.NoError(t, err)
assert.Equal(t, true, status)
flagReq.Update = engine.FlagNonSet
status, err = re.Flag(flagReq)
require.NoError(t, err)
assert.Equal(t, true, status)
}
func TestRPC_listFlagsHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
id, err := re.Create(c)
assert.NoError(t, err)
assert.Equal(t, "123456", id)
flagReq := engine.FlagRequest{
Flag: engine.Verified,
UserID: "u1",
Locator: store.Locator{
SiteID: "test-site",
},
}
flags, err := re.ListFlags(flagReq)
require.NoError(t, err)
assert.Equal(t, []interface{}{}, flags)
flagReq.Update = engine.FlagTrue
status, err := re.Flag(flagReq)
require.NoError(t, err)
assert.Equal(t, true, status)
flags, err = re.ListFlags(flagReq)
require.NoError(t, err)
assert.Equal(t, []interface{}{"u1"}, flags)
}
func TestRPC_userDetailHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
// add to entries to DB before we start
result, err := re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1", Detail: engine.UserEmail, Update: "test@example.com"})
assert.NoError(t, err, "No error inserting entry expected")
assert.ElementsMatch(t, []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}}, result)
result, err = re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u2", Detail: engine.UserEmail, Update: "other@example.com"})
assert.NoError(t, err, "No error inserting entry expected")
assert.ElementsMatch(t, []engine.UserDetailEntry{{UserID: "u2", Email: "other@example.com"}}, result)
// try to change existing entry with wrong SiteID
result, err = re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "u2", Detail: engine.UserEmail, Update: "not_relevant"})
assert.NoError(t, err, "Updating existing entry with wrong SiteID doesn't produce error")
assert.ElementsMatch(t, []engine.UserDetailEntry{}, result, "Updating existing entry with wrong SiteID doesn't change anything")
// stateless tests without changing the state we set up before
var testData = []struct {
req engine.UserDetailRequest
error string
expected []engine.UserDetailEntry
}{
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1", Detail: engine.UserEmail},
expected: []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}}},
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "u1", Detail: engine.UserEmail},
expected: []engine.UserDetailEntry{}},
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1xyz", Detail: engine.UserEmail},
expected: []engine.UserDetailEntry{}},
{req: engine.UserDetailRequest{Detail: engine.UserEmail, Update: "new_value"},
error: `userid cannot be empty in request for single detail`},
{req: engine.UserDetailRequest{Detail: engine.UserDetail("bad")},
error: `unsupported detail "bad"`},
{req: engine.UserDetailRequest{Update: "not_relevant", Detail: engine.AllUserDetails},
error: `unsupported request with userdetail all`},
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails},
expected: []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}, {UserID: "u2", Email: "other@example.com"}}},
}
for i, x := range testData {
result, err := re.UserDetail(x.req)
if x.error != "" {
assert.EqualError(t, err, x.error, "Error should match expected for case %d", i)
} else {
assert.NoError(t, err, "Error is not expected expected for case %d", i)
}
assert.ElementsMatch(t, x.expected, result, "Result should match expected for case %d", i)
}
}
func TestRPC_deleteHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
req := engine.DeleteRequest{
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
CommentID: "123456",
}
err := re.Delete(req)
assert.EqualError(t, err, "not found")
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
_, err = re.Create(c)
assert.NoError(t, err)
err = re.Delete(req)
assert.NoError(t, err)
}
func TestRPC_closeHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
err := re.Close()
assert.NoError(t, err)
}
@@ -0,0 +1,61 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"context"
"encoding/base64"
"encoding/json"
"time"
"github.com/go-pkgz/jrpc"
)
func (s *RPC) imgSaveWithIDHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var req [2]string
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
img, err := base64.StdEncoding.DecodeString(req[1])
if err != nil {
return jrpc.Response{Error: err.Error()}
}
err = s.img.Save(req[0], img)
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 {
return jrpc.Response{Error: err.Error()}
}
value, err := s.img.Load(fileID)
return jrpc.EncodeResponse(id, value, err)
}
func (s *RPC) imgCommitHndl(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.Commit(fileID)
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgCleanupHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var ttl time.Duration
if err := json.Unmarshal(params, &ttl); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.img.Cleanup(context.TODO(), ttl)
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgInfoHndl(id uint64, _ json.RawMessage) (rr jrpc.Response) {
info, err := s.img.Info()
return jrpc.EncodeResponse(id, info, err)
}
@@ -0,0 +1,148 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"context"
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"testing"
"time"
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark42/backend/app/store/image"
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
"JkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk" +
"8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAA" +
"ILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36I" +
"MRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2" +
"MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZm" +
"rSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhkt" +
"rRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0" +
"dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEj" +
"yy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67" +
"LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR" +
"11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5fe" +
"A+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fU" +
"yLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy" +
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNGBytes() []byte {
img, _ := ioutil.ReadAll(gopherPNG())
return img
}
func TestRPC_imgLoadHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
// save
id := "test_img"
err := ri.Save(id, gopherPNGBytes())
assert.NoError(t, err)
// load
img, err := ri.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
// commit
err = ri.Commit(id)
assert.NoError(t, err)
// load after commit
img, err = ri.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
// cleanup
err = ri.Cleanup(context.TODO(), time.Second)
assert.NoError(t, err)
// load after cleanup
img, err = ri.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
}
func TestRPC_imgCommitHndlFail(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
err := ri.Commit("test_id")
assert.EqualError(t, err, "failed to commit test_id, not found in staging")
}
func TestRPC_imgCleanupHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
// save
id := "test_img"
err := ri.Save(id, gopherPNGBytes())
assert.NoError(t, err)
// load
img, err := ri.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
// cleanup
err = ri.Cleanup(context.TODO(), time.Nanosecond)
assert.NoError(t, err)
// load after cleanup should fail
_, err = ri.Load(id)
assert.EqualError(t, err, "image test_img not found")
}
func TestRPC_imgInfoHndl(t *testing.T) {
port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
// get info on empty storage, should be zero
info, err := ri.Info()
assert.NoError(t, err)
assert.True(t, info.FirstStagingImageTS.IsZero())
// save
err = ri.Save("test_img", gopherPNGBytes())
assert.NoError(t, err)
// get info after saving, should be non-zero
info, err = ri.Info()
assert.NoError(t, err)
assert.False(t, info.FirstStagingImageTS.IsZero())
}
@@ -0,0 +1,66 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"github.com/go-pkgz/jrpc"
"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
// Note: this file can be used as-is in any custom jrpc plugin
type RPC struct {
*jrpc.Server
eng engine.Interface
adm admin.Store
img image.Store
}
// NewRPC makes RPC instance and register handlers
func NewRPC(e engine.Interface, a admin.Store, i image.Store, r *jrpc.Server) *RPC {
res := &RPC{eng: e, adm: a, img: i, Server: r}
res.addHandlers()
return res
}
func (s *RPC) addHandlers() {
// data store handlers
s.Group("store", jrpc.HandlersGroup{
"create": s.createHndl,
"find": s.findHndl,
"get": s.getHndl,
"update": s.updateHndl,
"count": s.countHndl,
"info": s.infoHndl,
"flag": s.flagHndl,
"list_flags": s.listFlagsHndl,
"user_detail": s.userDetailHndl,
"delete": s.deleteHndl,
"close": s.closeHndl,
})
// admin store handlers
s.Group("admin", jrpc.HandlersGroup{
"key": s.admKeyHndl,
"admins": s.admAdminsHndl,
"email": s.admEmailHndl,
"enabled": s.admEnabledHndl,
"event": s.admEventHndl,
})
// 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,
})
}
@@ -0,0 +1,74 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"fmt"
"math/rand"
"net"
"net/http"
"testing"
"time"
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/memory_store/accessor"
)
func chooseRandomUnusedPort() (port int) {
for i := 0; i < 10; i++ {
port = 40000 + int(rand.Int31n(10000))
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
_ = ln.Close()
break
}
}
return port
}
func waitForHTTPServerStart(port int) {
// wait for up to 3 seconds for server to start before returning it
client := http.Client{Timeout: time.Second}
for i := 0; i < 300; i++ {
time.Sleep(time.Millisecond * 10)
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
_ = resp.Body.Close()
return
}
}
}
func prepTestStore(t *testing.T) (port int, teardown func()) {
mg := accessor.NewMemData()
adm := accessor.NewMemAdminStore("secret")
img := accessor.NewMemImageStore()
s := NewRPC(mg, adm, img, &jrpc.Server{API: "/test", Logger: jrpc.NoOpLogger})
admRec := accessor.AdminRec{
SiteID: "test-site",
IDs: []string{"id1", "id2"},
Email: "admin@example.com",
Enabled: true,
}
adm.Set("test-site", admRec)
admRecDisabled := admRec
admRecDisabled.Enabled = false
adm.Set("test-site-disabled", admRecDisabled)
port = chooseRandomUnusedPort()
go func() {
_ = s.Run(port)
}()
waitForHTTPServerStart(port)
return port, func() {
require.NoError(t, s.Shutdown())
}
}
+4 -21
View File
@@ -2,14 +2,12 @@ package cmd
import (
"path"
"time"
bolt "github.com/coreos/bbolt"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
bolt "go.etcd.io/bbolt"
"github.com/go-pkgz/auth/avatar"
"github.com/go-pkgz/mongo"
)
// AvatarCommand set of flags and command for avatar migration
@@ -18,7 +16,6 @@ import (
type AvatarCommand struct {
AvatarSrc AvatarGroup `group:"src" namespace:"src"`
AvatarDst AvatarGroup `group:"dst" namespace:"dst"`
Mongo MongoGroup `group:"mongo" namespace:"mongo" env-namespace:"MONGO"`
migrator AvatarMigrator
CommonOpts
@@ -37,7 +34,7 @@ func (a avatarMigrator) Migrate(dst, src avatar.Store) (int, error) {
}
// Execute runs with AvatarCommand parameters, entry point for "avatar" command
func (ac *AvatarCommand) Execute(args []string) error {
func (ac *AvatarCommand) Execute(_ []string) error {
log.Printf("[INFO] migrate avatars from %s to %s", ac.AvatarSrc.Type, ac.AvatarDst.Type)
src, err := ac.makeAvatarStore(ac.AvatarSrc)
@@ -75,28 +72,14 @@ func (ac *AvatarCommand) makeAvatarStore(gr AvatarGroup) (avatar.Store, error) {
switch gr.Type {
case "fs":
if err := makeDirs(gr.FS.Path); err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to create avatar store")
}
return avatar.NewLocalFS(gr.FS.Path), nil
case "mongo":
mgServer, err := ac.makeMongo()
if err != nil {
return nil, errors.Wrap(err, "failed to create mongo server")
}
conn := mongo.NewConnection(mgServer, ac.Mongo.DB, "")
return avatar.NewGridFS(conn), 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{})
}
return nil, errors.Errorf("unsupported avatar store type %s", gr.Type)
}
func (ac *AvatarCommand) makeMongo() (result *mongo.Server, err error) {
if ac.Mongo.URL == "" {
return nil, errors.New("no mongo URL provided")
}
return mongo.NewServerWithURL(ac.Mongo.URL, 10*time.Second)
}
+8 -25
View File
@@ -6,39 +6,22 @@ import (
"testing"
"github.com/go-pkgz/auth/avatar"
flags "github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/go-flags"
)
func TestAvatar_Execute(t *testing.T) {
mongoURL := os.Getenv("MONGO_TEST")
if mongoURL == "" {
mongoURL = "mongodb://localhost:27017/test"
}
if mongoURL == "skip" {
t.Skip("skip mongo app test")
}
defer os.RemoveAll("/tmp/ava-test")
// from fs to mongo
// from fs to bolt
cmd := AvatarCommand{migrator: &avatarMigratorMock{retCount: 100}}
cmd.SetCommon(CommonOpts{RemarkURL: "", SharedSecret: "123456"})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=mongo",
"--mongo.url=" + mongoURL, "--mongo.db=test_remark"})
require.Nil(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
// from fs to bolt
cmd = AvatarCommand{migrator: &avatarMigratorMock{retCount: 100}}
cmd.SetCommon(CommonOpts{RemarkURL: "", SharedSecret: "123456"})
p = flags.NewParser(&cmd, flags.Default)
_, err = p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=bolt",
_, err := p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=bolt",
"--dst.bolt.file=/tmp/ava-test.db"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
@@ -46,9 +29,9 @@ func TestAvatar_Execute(t *testing.T) {
cmd = AvatarCommand{migrator: &avatarMigratorMock{retCount: 0, retError: errors.New("failed blah")}}
cmd.SetCommon(CommonOpts{RemarkURL: "", SharedSecret: "123456"})
p = flags.NewParser(&cmd, flags.Default)
_, err = p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=mongo",
"--mongo.url=" + mongoURL, "--mongo.db=test_remark"})
require.Nil(t, err)
_, err = p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=bolt",
"--dst.bolt.file=/tmp/ava-test2.db"})
require.NoError(t, err)
err = cmd.Execute(nil)
assert.Error(t, err, "failed blah")
}
@@ -59,7 +42,7 @@ type avatarMigratorMock struct {
retCount int
}
func (a *avatarMigratorMock) Migrate(dst, src avatar.Store) (int, error) {
func (a *avatarMigratorMock) Migrate(_, _ avatar.Store) (int, error) {
a.called++
return a.retCount, a.retError
}
+1 -1
View File
@@ -24,7 +24,7 @@ type BackupCommand struct {
}
// Execute runs export with ExportCommand parameters, entry point for "export" command
func (ec *BackupCommand) Execute(args []string) error {
func (ec *BackupCommand) Execute(_ []string) error {
log.Printf("[INFO] export to %s, site %s", ec.ExportPath, ec.Site)
resetEnv("SECRET", "ADMIN_PASSWD")
+6 -5
View File
@@ -8,7 +8,8 @@ import (
"os"
"testing"
flags "github.com/jessevdk/go-flags"
"github.com/umputun/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -25,13 +26,13 @@ func TestBackup_Execute(t *testing.T) {
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
defer os.Remove("/tmp/remark-test.export")
data, err := ioutil.ReadFile("/tmp/remark-test.export")
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(data))
}
@@ -49,7 +50,7 @@ func TestBackup_ExecuteFailedStatus(t *testing.T) {
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.EqualError(t, err, `error response "400 Bad Request", some error`)
}
@@ -68,7 +69,7 @@ func TestBackup_ExecuteFailedWrite(t *testing.T) {
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp",
"--file=/tmp/no-such-dir/{{.SITE}}-test.export", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.EqualError(t, err, `can't create backup file /tmp/no-such-dir/remark-test.export: open /tmp/no-such-dir/remark-test.export: no such file or directory`)
}
+7 -5
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
@@ -34,7 +34,7 @@ var (
// Execute runs cleanup with CleanupCommand parameters, entry point for "cleanup" command
// This command uses provided flags to detect and remove junk comments
func (cc *CleanupCommand) Execute(args []string) error {
func (cc *CleanupCommand) Execute(_ []string) error {
log.Printf("[INFO] cleanup for site %s", cc.Site)
posts, err := cc.postsInRange(cc.From, cc.To)
@@ -131,7 +131,8 @@ func (cc *CleanupCommand) postsInRange(fromS, toS string) ([]store.PostInfo, err
// get all posts via GET /list?site=siteID&limit=50&skip=10
func (cc *CleanupCommand) listPosts() ([]store.PostInfo, error) {
listURL := fmt.Sprintf("%s/api/v1/list?site=%s&limit=10000", cc.RemarkURL, cc.Site)
r, err := http.Get(listURL) // nolint
client := http.Client{Timeout: 30 * time.Second}
r, err := client.Get(listURL)
if err != nil {
return nil, errors.Wrapf(err, "get request failed for list of posts, site %s", cc.Site)
}
@@ -158,7 +159,8 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error)
// handle 429 error from limiter
for {
r, err = http.Get(commentsURL) // nolint
client := http.Client{Timeout: 30 * time.Second}
r, err = client.Get(commentsURL)
if err != nil {
return nil, errors.Wrapf(err, "get request failed for comments, %s", postURL)
}
@@ -232,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
+17 -16
View File
@@ -9,12 +9,12 @@ import (
"testing"
"time"
"github.com/go-chi/chi"
flags "github.com/jessevdk/go-flags"
"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 {
@@ -46,6 +46,7 @@ func TestCleanup_IsSpam(t *testing.T) {
}
for n, tt := range tbl {
tt := tt
checkName := fmt.Sprintf("check-%d-%s", n, tt.name)
t.Run(checkName, func(t *testing.T) {
c := store.Comment{ID: checkName, Text: tt.text, Score: tt.score}
@@ -68,7 +69,7 @@ func TestCleanup_postsInRange(t *testing.T) {
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
posts, err := cmd.postsInRange("20181218", "20181219")
assert.NoError(t, err)
assert.Equal(t, 2, len(posts))
@@ -78,7 +79,7 @@ func TestCleanup_postsInRange(t *testing.T) {
assert.Equal(t, 3, len(posts))
_, err = cmd.postsInRange("xxx", "yyy")
assert.NotNil(t, err)
assert.Error(t, err)
}
func TestCleanup_listComments(t *testing.T) {
@@ -91,7 +92,7 @@ func TestCleanup_listComments(t *testing.T) {
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
comments, err := cmd.listComments("http://test.com/post1")
assert.NoError(t, err)
@@ -118,7 +119,7 @@ func TestCleanup_ExecuteSpam(t *testing.T) {
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--bword=bad1", "--bword=bad2", "--buser=bu_",
"--from=20181217", "--to=20181218", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
t.Logf("deleted %+v", cleaned.ids)
@@ -136,7 +137,7 @@ func TestCleanup_ExecuteTitle(t *testing.T) {
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--title", "--from=20181217", "--to=20181218", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
t.Logf("set titles for %+v", titledComments.ids)
@@ -144,7 +145,7 @@ func TestCleanup_ExecuteTitle(t *testing.T) {
}
func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
r.HandleFunc("/api/v1/list", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("/api/v1/list", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "GET", r.Method)
require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery)
list := []store.PostInfo{
@@ -165,9 +166,9 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
},
}
require.NoError(t, json.NewEncoder(w).Encode(list))
}))
})
r.HandleFunc("/api/v1/find", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("/api/v1/find", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "GET", r.Method)
require.Equal(t, "remark", r.URL.Query().Get("site"))
require.Equal(t, "plain", r.URL.Query().Get("format"))
@@ -193,22 +194,22 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
}
require.NoError(t, json.NewEncoder(w).Encode(commentsWithInfo))
}))
})
r.HandleFunc("/api/v1/admin/comment/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("/api/v1/admin/comment/{id}", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "DELETE", r.Method)
t.Log("delete ", r.URL.Path)
c.lock.Lock()
c.ids = append(c.ids, r.URL.Path)
c.lock.Unlock()
}))
})
r.HandleFunc("/api/v1/admin/title/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("/api/v1/admin/title/{id}", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "PUT", r.Method)
t.Log("title for ", r.URL.Path)
c.lock.Lock()
c.ids = append(c.ids, r.URL.Path)
c.lock.Unlock()
}))
})
}
+11
View File
@@ -21,6 +21,7 @@ import (
type CommonOptionsCommander interface {
SetCommon(commonOpts CommonOpts)
Execute(args []string) error
HandleDeprecatedFlags() []DeprecatedFlag
}
// CommonOpts sets externally from main, shared across all commands
@@ -30,6 +31,13 @@ type CommonOpts struct {
Revision string
}
// DeprecatedFlag contains information about deprecated option
type DeprecatedFlag struct {
Old string
New string
RemoveVersion string
}
// SetCommon satisfies CommonOptionsCommander interface and sets common option fields
// The method called by main for each command
func (c *CommonOpts) SetCommon(commonOpts CommonOpts) {
@@ -38,6 +46,9 @@ func (c *CommonOpts) SetCommon(commonOpts CommonOpts) {
c.Revision = commonOpts.Revision
}
// HandleDeprecatedFlags sets new flags from deprecated and returns their list
func (c *CommonOpts) HandleDeprecatedFlags() []DeprecatedFlag { return nil }
// fileParser used to convert template strings like blah-{{.SITE}}-{{.YYYYMMDD}} the final format
type fileParser struct {
site string
+1 -1
View File
@@ -31,7 +31,7 @@ func TestExport_ParseFileName(t *testing.T) {
for i, tt := range tbl {
r, err := tt.p.parse(now)
if tt.err {
assert.NotNil(t, err)
assert.Error(t, err)
continue
}
assert.Equal(t, tt.res, r, "check #%d", i)
+3 -3
View File
@@ -18,7 +18,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"`
Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" description:"import format"` //nolint
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
@@ -26,7 +26,7 @@ type ImportCommand struct {
}
// Execute runs import with ImportCommand parameters, entry point for "import" command
func (ic *ImportCommand) Execute(args []string) error {
func (ic *ImportCommand) Execute(_ []string) error {
log.Printf("[INFO] import %s (%s), site %s", ic.InputFile, ic.Provider, ic.Site)
resetEnv("SECRET", "ADMIN_PASSWD")
@@ -69,7 +69,7 @@ func (ic *ImportCommand) Execute(args []string) error {
// reader returns reader for file. For .gz file wraps with gunzip
func (ic *ImportCommand) reader(inp string) (reader io.Reader, err error) {
inpFile, err := os.Open(inp)
inpFile, err := os.Open(inp) // nolint
if err != nil {
return nil, errors.Wrapf(err, "import failed, can't open %s", inp)
}
+17 -17
View File
@@ -5,12 +5,12 @@ import (
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
log "github.com/go-pkgz/lgr"
flags "github.com/jessevdk/go-flags"
"github.com/umputun/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -21,7 +21,7 @@ func TestImport_Execute(t *testing.T) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
body, err := ioutil.ReadAll(r.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
fmt.Fprintln(w, "some response")
@@ -34,7 +34,7 @@ func TestImport_Execute(t *testing.T) {
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
@@ -43,7 +43,7 @@ func TestImport_Execute(t *testing.T) {
p = flags.NewParser(&cmd, flags.Default)
_, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt.gz", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
}
@@ -61,21 +61,21 @@ func TestImport_ExecuteFailed(t *testing.T) {
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import-no.txt", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
t.Log(err)
assert.NotNil(t, err, "fail on no such file")
assert.True(t, strings.Contains(err.Error(), "no such file or directory"))
assert.Error(t, err, "fail on no such file")
assert.Contains(t, err.Error(), "no such file or directory")
cmd = ImportCommand{}
cmd.SetCommon(CommonOpts{RemarkURL: "http://127.0.0.1:12345", SharedSecret: "123456"})
p = flags.NewParser(&cmd, flags.Default)
_, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
t.Log(err)
assert.NotNil(t, err, "fail on connection refused")
assert.True(t, strings.Contains(err.Error(), "connection refused"))
assert.Error(t, err, "fail on connection refused")
assert.Contains(t, err.Error(), "connection refused")
ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%+v", r)
@@ -87,10 +87,10 @@ func TestImport_ExecuteFailed(t *testing.T) {
cmd.SetCommon(CommonOpts{RemarkURL: ts2.URL, SharedSecret: "123456"})
p = flags.NewParser(&cmd, flags.Default)
_, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
t.Log(err)
assert.NotNil(t, err)
assert.Error(t, err)
}
func TestImport_ExecuteTimeout(t *testing.T) {
@@ -98,7 +98,7 @@ func TestImport_ExecuteTimeout(t *testing.T) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
assert.Equal(t, "POST", r.Method)
body, err := ioutil.ReadAll(r.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
time.Sleep(500 * time.Millisecond)
fmt.Fprintln(w, "some response")
@@ -112,8 +112,8 @@ func TestImport_ExecuteTimeout(t *testing.T) {
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt", "--timeout=300ms", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NotNil(t, err)
assert.True(t, strings.Contains(err.Error(), "deadline exceeded"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "deadline exceeded")
}
+65
View File
@@ -0,0 +1,65 @@
package cmd
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"os"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
)
// RemapCommand set of flags and command for change linkage between comments to
// different urls based on given rules (input file)
type RemapCommand struct {
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
Timeout time.Duration `long:"timeout" default:"15m" description:"remap timeout"`
CommonOpts
}
// Execute runs (re)mapper with RemapCommand parameters, entry point for "remap" command
func (rc *RemapCommand) Execute(_ []string) error {
log.Printf("[INFO] start remap, site %s, file with rules %s", rc.Site, rc.InputFile)
resetEnv("SECRET", "ADMIN_PASSWD")
rulesReader, err := os.Open(rc.InputFile)
if err != nil {
return errors.Wrapf(err, "cant open file %s", rc.InputFile)
}
client := http.Client{}
ctx, cancel := context.WithTimeout(context.Background(), rc.Timeout)
defer cancel()
remapURL := fmt.Sprintf("%s/api/v1/admin/remap?site=%s", rc.RemarkURL, rc.Site)
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader)
if err != nil {
return errors.Wrapf(err, "can't make remap request for %s", remapURL)
}
req.SetBasicAuth("admin", rc.AdminPasswd)
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return errors.Wrapf(err, "request failed for %s", remapURL)
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("[WARN] failed to close response, %s", err)
}
}()
if resp.StatusCode >= 300 {
return responseError(resp)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "can't get response")
}
log.Printf("[INFO] completed, status=%d, %s", resp.StatusCode, string(body))
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package cmd
import (
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/umputun/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRemap_Execute(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "remark", r.URL.Query().Get("site"))
body, err := ioutil.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, "http://oldsite.com* https://newsite.com*\nhttp://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1", string(body))
w.WriteHeader(202)
}))
defer ts.Close()
cmd := RemapCommand{}
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL, SharedSecret: "123456"})
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/remap_urls.txt", "--admin-passwd=secret"})
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
}
+4 -3
View File
@@ -7,7 +7,8 @@ import (
"net/http/httptest"
"testing"
flags "github.com/jessevdk/go-flags"
"github.com/umputun/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -19,7 +20,7 @@ func TestRestore_Execute(t *testing.T) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "native", r.URL.Query().Get("provider"))
body, err := ioutil.ReadAll(r.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
fmt.Fprintln(w, "some response")
@@ -32,7 +33,7 @@ func TestRestore_Execute(t *testing.T) {
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--site=remark", "--path=testdata", "--file=import.txt", "--admin-passwd=secret"})
require.Nil(t, err)
require.NoError(t, err)
err = cmd.Execute(nil)
assert.NoError(t, err)
}
+535 -188
View File
File diff suppressed because it is too large Load Diff
+387 -161
View File
@@ -6,63 +6,67 @@ import (
"fmt"
"io/ioutil"
"math/rand"
"net"
"net/http"
"os"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/globalsign/mgo"
"github.com/go-pkgz/auth/token"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/mongo"
"github.com/jessevdk/go-flags"
"github.com/umputun/go-flags"
"go.uber.org/goleak"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServerApp(t *testing.T) {
port := rand.Intn(40000) + 10000
app, ctx := prepServerApp(t, 1500*time.Millisecond, func(o ServerCommand) ServerCommand {
port := chooseRandomUnusedPort()
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port
return o
})
go func() { _ = app.run(ctx) }()
time.Sleep(100 * time.Millisecond) // let server start
waitForHTTPServerStart(port)
// send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
// add comment
client := http.Client{Timeout: 5 * time.Second}
client := http.Client{Timeout: 10 * time.Second}
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)
req.SetBasicAuth("admin", "password")
require.Nil(t, err)
resp, err = client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
body, _ = ioutil.ReadAll(resp.Body)
t.Log(string(body))
assert.Equal(t, "admin@demo.remark42.com", app.dataService.AdminStore.Email(""), "default admin email")
email, err := app.dataService.AdminStore.Email("")
assert.NoError(t, err)
assert.Equal(t, "admin@demo.remark42.com", email, "default admin email")
cancel()
app.Wait()
}
func TestServerApp_DevMode(t *testing.T) {
port := rand.Intn(40000) + 10000
app, ctx := prepServerApp(t, 500*time.Millisecond, func(o ServerCommand) ServerCommand {
port := chooseRandomUnusedPort()
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port
o.AdminPasswd = "password"
o.Auth.Dev = true
@@ -70,145 +74,161 @@ func TestServerApp_DevMode(t *testing.T) {
})
go func() { _ = app.run(ctx) }()
time.Sleep(100 * time.Millisecond) // let server start
waitForHTTPServerStart(port)
assert.Equal(t, 4+1, len(app.restSrv.Authenticator.Providers()), "extra auth provider")
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")
// send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.Nil(t, err)
defer resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, "pong", string(body))
cancel()
app.Wait()
}
func TestServerApp_AnonMode(t *testing.T) {
port := rand.Intn(40000) + 10000
app, ctx := prepServerApp(t, 500*time.Millisecond, func(o ServerCommand) ServerCommand {
port := chooseRandomUnusedPort()
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port
o.Auth.Anonymous = true
return o
})
go func() { _ = app.run(ctx) }()
time.Sleep(100 * time.Millisecond) // let server start
waitForHTTPServerStart(port)
assert.Equal(t, 4+1, len(app.restSrv.Authenticator.Providers()), "extra auth provider for anon")
assert.Equal(t, "anonymous", app.restSrv.Authenticator.Providers()[4].Name(), "anon auth provider")
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")
// send ping
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
// try to login with good name
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark42", port))
require.Nil(t, err)
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// try to add a comment as good anonymous
client := http.Client{Timeout: 10 * time.Second}
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)
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=remark42", port))
require.Nil(t, err)
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 403, resp.StatusCode)
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=remark42", port))
require.Nil(t, err)
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, 403, resp.StatusCode)
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))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// try to add a comment as anonymous with admin name
client = http.Client{Timeout: 10 * time.Second}
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.True(t, claims.User.BoolAttr("blocked"), "should 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.StatusUnauthorized, resp.StatusCode)
cancel()
app.Wait()
}
func TestServerApp_WithMongo(t *testing.T) {
mongoURL := os.Getenv("MONGO_TEST")
if mongoURL == "" {
mongoURL = "mongodb://localhost:27017/test"
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" {
tkn = c.Value
claims, err = app.restSrv.Authenticator.TokenService().Parse(c.Value)
require.NoError(t, err)
}
}
if mongoURL == "skip" {
t.Skip("skip mongo app test")
}
opts := ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
// prepare options
p := flags.NewParser(&opts, flags.Default)
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none", "--store.type=mongo",
"--avatar.type=mongo", "--mongo.url=" + mongoURL, "--mongo.db=test_remark", "--port=12345", "--admin.type=mongo"})
require.Nil(t, err)
opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid"
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
// create app
app, err := opts.newServerApp()
require.Nil(t, err)
defer func() {
s, e := mongo.NewServerWithURL(mongoURL, 10*time.Second)
assert.NoError(t, e)
conn := mongo.NewConnection(s, "test_remark", "")
_ = conn.WithDB(func(dbase *mgo.Database) error {
assert.NoError(t, dbase.DropDatabase())
return nil
})
}()
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(5 * time.Second)
log.Print("[TEST] terminate app")
cancel()
}()
go func() { _ = app.run(ctx) }()
time.Sleep(100 * time.Millisecond) // let server start
// send ping
resp, err := http.Get("http://localhost:12345/api/v1/ping")
require.Nil(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.Equal(t, "pong", string(body))
app.Wait()
return tkn, claims
}
func TestServerApp_WithSSL(t *testing.T) {
opts := ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://localhost:18443", SharedSecret: "123456"})
sslPort := chooseRandomUnusedPort()
opts.SetCommon(CommonOpts{RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), SharedSecret: "123456"})
// prepare options
p := flags.NewParser(&opts, flags.Default)
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=18080", "--store.bolt.path=/tmp/xyz", "--backup=/tmp",
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",
"--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem",
"--ssl.port=18443", "--image.fs.path=/tmp"})
require.Nil(t, err)
"--ssl.port=" + strconv.Itoa(sslPort), "--image.fs.path=/tmp"})
require.NoError(t, err)
// create app
app, err := opts.newServerApp()
require.Nil(t, err)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(1 * time.Second)
log.Print("[TEST] terminate app")
cancel()
}()
go func() { _ = app.run(ctx) }()
time.Sleep(100 * time.Millisecond) // let server start
waitForHTTPSServerStart(sslPort)
client := http.Client{
// prevent http redirect
@@ -223,21 +243,58 @@ func TestServerApp_WithSSL(t *testing.T) {
}
// check http to https redirect response
resp, err := client.Get("http://localhost:18080/blah?param=1")
require.Nil(t, err)
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/blah?param=1", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 307, resp.StatusCode)
assert.Equal(t, "https://localhost:18443/blah?param=1", resp.Header.Get("Location"))
assert.Equal(t, fmt.Sprintf("https://localhost:%d/blah?param=1", sslPort), resp.Header.Get("Location"))
// check https server
resp, err = client.Get("https://localhost:18443/ping")
require.Nil(t, err)
resp, err = client.Get(fmt.Sprintf("https://localhost:%d/ping", sslPort))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
cancel()
app.Wait()
}
func TestServerApp_WithRemote(t *testing.T) {
opts := ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
// prepare options
p := flags.NewParser(&opts, flags.Default)
port := chooseRandomUnusedPort()
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
"--store.type=rpc", "--store.rpc.api=http://127.0.0.1",
"--port=" + strconv.Itoa(port), "--admin.type=rpc", "--admin.rpc.api=http://127.0.0.1", "--avatar.fs.path=/tmp"})
require.NoError(t, err)
opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid"
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
// create app
app, err := opts.newServerApp()
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
go func() { _ = app.run(ctx) }()
waitForHTTPServerStart(port)
// 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)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
cancel()
app.Wait()
}
@@ -249,7 +306,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.Nil(t, err)
assert.NoError(t, err)
_, err = opts.newServerApp()
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)
@@ -259,9 +316,9 @@ func TestServerApp_Failed(t *testing.T) {
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
_, err = p.ParseArgs([]string{"--store.bolt.path=/tmp", "--backup=/dev/null/not-writable"})
assert.Nil(t, err)
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")
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
@@ -269,7 +326,7 @@ func TestServerApp_Failed(t *testing.T) {
opts.SetCommon(CommonOpts{RemarkURL: "demo.remark42.com", SharedSecret: "123456"})
_, err = p.ParseArgs([]string{"--backup=/tmp", "----store.bolt.path=/tmp"})
assert.Nil(t, err)
assert.NoError(t, err)
_, err = opts.newServerApp()
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
t.Log(err)
@@ -278,47 +335,110 @@ func TestServerApp_Failed(t *testing.T) {
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
_, err = p.ParseArgs([]string{"--backup=/tmp", "--store.type=blah"})
assert.NotNil(t, err, "blah is invalid type")
assert.Error(t, err, "blah is invalid type")
opts.Store.Type = "blah"
_, err = opts.newServerApp()
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()
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) {
app, ctx := prepServerApp(t, 500*time.Millisecond, func(o ServerCommand) ServerCommand {
o.Port = rand.Intn(40000) + 10000
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = chooseRandomUnusedPort()
return o
})
time.AfterFunc(100*time.Millisecond, func() {
cancel()
})
st := time.Now()
err := app.run(ctx)
assert.Nil(t, err)
assert.True(t, time.Since(st).Seconds() < 1, "should take about 500msec")
assert.NoError(t, err)
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100msec")
app.Wait()
}
func TestServerApp_MainSignal(t *testing.T) {
done := make(chan struct{})
go func() {
<-done
time.Sleep(250 * time.Millisecond)
err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
require.Nil(t, err)
require.NoError(t, err)
}()
st := time.Now()
s := ServerCommand{}
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
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=18100", "--notify.type=none", "--image.fs.path=/tmp"}
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--notify.type=none", "--image.fs.path=/tmp"}
defer os.Remove("/tmp/ava-test.db")
_, err := p.ParseArgs(args)
require.Nil(t, err)
require.NoError(t, err)
st := time.Now()
close(done)
err = s.Execute(args)
assert.NoError(t, err, "execute failed")
assert.True(t, time.Since(st).Seconds() < 1, "should take about 500msec")
assert.NoError(t, err, "execute should be without errors")
assert.True(t, time.Since(st).Seconds() < 5, "should take under five sec", time.Since(st).Seconds())
}
func TestServerApp_DeprecatedArgs(t *testing.T) {
s := ServerCommand{}
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
p := flags.NewParser(&s, flags.Default)
args := []string{
"test",
"--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",
}
assert.Empty(t, s.SMTP.Host)
assert.Empty(t, s.SMTP.Port)
assert.Empty(t, s.SMTP.TLS)
assert.Empty(t, s.SMTP.Username)
assert.Empty(t, s.SMTP.Password)
assert.Empty(t, s.SMTP.TimeOut)
_, err := p.ParseArgs(args)
require.NoError(t, err)
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.template", RemoveVersion: "1.9.0"},
},
deprecatedFlags)
assert.Equal(t, "smtp.example.org", s.SMTP.Host)
assert.Equal(t, 666, s.SMTP.Port)
assert.Equal(t, true, s.SMTP.TLS)
assert.Equal(t, "test_user", s.SMTP.Username)
assert.Equal(t, "test_password", s.SMTP.Password)
assert.Equal(t, 15*time.Second, s.SMTP.TimeOut)
}
func Test_ACMEEmail(t *testing.T) {
@@ -327,9 +447,9 @@ func Test_ACMEEmail(t *testing.T) {
p := flags.NewParser(&cmd, flags.Default)
args := []string{"--ssl.type=auto"}
_, err := p.ParseArgs(args)
require.Nil(t, err)
require.NoError(t, err)
cfg, err := cmd.makeSSLConfig()
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "admin@remark.com", cfg.ACMEEmail)
cmd = ServerCommand{}
@@ -337,9 +457,9 @@ func Test_ACMEEmail(t *testing.T) {
p = flags.NewParser(&cmd, flags.Default)
args = []string{"--ssl.type=auto", "--ssl.acme-email=adminname@adminhost.com"}
_, err = p.ParseArgs(args)
require.Nil(t, err)
require.NoError(t, err)
cfg, err = cmd.makeSSLConfig()
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "adminname@adminhost.com", cfg.ACMEEmail)
cmd = ServerCommand{}
@@ -347,9 +467,9 @@ func Test_ACMEEmail(t *testing.T) {
p = flags.NewParser(&cmd, flags.Default)
args = []string{"--ssl.type=auto", "--admin.type=shared", "--admin.shared.email=superadmin@admin.com"}
_, err = p.ParseArgs(args)
require.Nil(t, err)
require.NoError(t, err)
cfg, err = cmd.makeSSLConfig()
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "superadmin@admin.com", cfg.ACMEEmail)
cmd = ServerCommand{}
@@ -357,21 +477,21 @@ func Test_ACMEEmail(t *testing.T) {
p = flags.NewParser(&cmd, flags.Default)
args = []string{"--ssl.type=auto", "--admin.type=shared"}
_, err = p.ParseArgs(args)
require.Nil(t, err)
require.NoError(t, err)
cfg, err = cmd.makeSSLConfig()
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "admin@remark.com", cfg.ACMEEmail)
}
func TestServerAuthHooks(t *testing.T) {
port := rand.Intn(40000) + 10000
app, ctx := prepServerApp(t, 5*time.Second, func(o ServerCommand) ServerCommand {
port := chooseRandomUnusedPort()
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
o.Port = port
return o
})
go func() { _ = app.run(ctx) }()
time.Sleep(100 * time.Millisecond) // let server start
waitForHTTPServerStart(port)
// make a token for user dev
tkService := app.restSrv.Authenticator.TokenService()
@@ -394,52 +514,144 @@ func TestServerAuthHooks(t *testing.T) {
t.Log(tk)
// add comment
client := http.Client{Timeout: 1 * time.Second}
client := http.Client{Timeout: 10 * time.Second}
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-630/", "site": "remark"}}`))
require.NoError(t, err)
req.Header.Set("X-JWT", tk)
require.Nil(t, err)
resp, err := client.Do(req)
require.Nil(t, err)
defer resp.Body.Close()
require.NoError(t, err)
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
claimsNoAud := claims
claimsNoAud.Audience = ""
tkNoAud, err := tkService.Token(claimsNoAud)
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"}}`))
require.NoError(t, err)
req.Header.Set("X-JWT", tkNoAud)
resp, err = client.Do(req)
require.NoError(t, err)
body, err := ioutil.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, e := http.NewRequest(http.MethodPut,
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/dev?site=remark&block=1&ttl=10d", port), nil)
assert.Nil(t, e)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, e = client.Do(req)
require.Nil(t, e)
defer resp.Body.Close()
resp, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode, "user dev blocked")
b, err := ioutil.ReadAll(resp.Body)
require.Nil(t, err)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
t.Log(string(b))
time.Sleep(2 * time.Second) // make sure token expired and refresh happened
// try add a comment with blocked user
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
strings.NewReader(`{"text": "test 123 blah", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
require.NoError(t, err)
req.Header.Set("X-JWT", tk)
require.Nil(t, err)
resp, err = client.Do(req)
require.Nil(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "blocked user can't post")
require.NoError(t, err)
body, err = ioutil.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 prepServerApp(t *testing.T, duration time.Duration, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context) {
func TestServer_loadEmailTemplate(t *testing.T) {
cmd := ServerCommand{}
cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl"
r, err := cmd.loadEmailTemplate()
assert.NoError(t, err)
assert.Equal(t, "The token is {{.Token}}", r)
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, "")
}
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 chooseRandomUnusedPort() (port int) {
for i := 0; i < 10; i++ {
port = 40000 + int(rand.Int31n(10000))
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
_ = ln.Close()
break
}
}
return port
}
func waitForHTTPServerStart(port int) {
// wait for up to 3 seconds for server to start before returning it
client := http.Client{Timeout: time.Second}
for i := 0; i < 300; i++ {
time.Sleep(time.Millisecond * 10)
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
_ = resp.Body.Close()
return
}
}
}
func waitForHTTPSServerStart(port int) {
// wait for up to 3 seconds for HTTPS server to start
for i := 0; i < 300; i++ {
time.Sleep(time.Millisecond * 10)
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
if conn != nil {
_ = conn.Close()
break
}
}
}
func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
cmd := ServerCommand{}
cmd.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "secret"})
// prepare options
p := flags.NewParser(&cmd, flags.Default)
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--site=remark"})
require.Nil(t, err)
require.NoError(t, err)
cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp", "fs", "/tmp", "/tmp"
cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port)
cmd.Store.Bolt.Timeout = 10 * time.Second
@@ -447,24 +659,38 @@ func prepServerApp(t *testing.T, duration time.Duration, fn func(o ServerCommand
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.Email.Enable = true
cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl"
cmd.BackupLocation = "/tmp"
cmd.Notify.Type = "telegram"
cmd.Notify.Telegram.API = "http://127.0.0.1:12340/"
cmd.Notify.Telegram.Token = "blah"
cmd.Notify.Type = []string{"email"}
cmd.Notify.Email.From = "from@example.org"
cmd.Notify.Email.VerificationSubject = "test verification email subject"
cmd.SMTP.Host = "127.0.0.1"
cmd.SMTP.Port = 25
cmd.SMTP.Username = "test_user"
cmd.SMTP.Password = "test_password"
cmd.SMTP.TimeOut = time.Second
cmd.UpdateLimit = 10
cmd.Admin.Type = "shared"
cmd.Admin.Shared.Admins = []string{"id1", "id2"}
cmd.RestrictedNames = []string{"umputun", "bobuk"}
cmd = fn(cmd)
os.Remove(cmd.Store.Bolt.Path + "/remark.db")
// create app
return createAppFromCmd(t, cmd)
}
func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
app, err := cmd.newServerApp()
require.Nil(t, err)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(duration)
log.Print("[TEST] terminate app")
cancel()
}()
rand.Seed(time.Now().UnixNano())
return app, ctx
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"))
}
+1
View File
@@ -0,0 +1 @@
The token is {{.Token}}
+2
View File
@@ -0,0 +1,2 @@
http://oldsite.com* https://newsite.com*
http://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1
+8 -2
View File
@@ -8,9 +8,9 @@ import (
"syscall"
log "github.com/go-pkgz/lgr"
"github.com/jessevdk/go-flags"
"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
@@ -21,6 +21,7 @@ type Opts struct {
RestoreCmd cmd.RestoreCommand `command:"restore"`
AvatarCmd cmd.AvatarCommand `command:"avatar"`
CleanupCmd cmd.CleanupCommand `command:"cleanup"`
RemapCmd cmd.RemapCommand `command:"remap"`
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"`
SharedSecret string `long:"secret" env:"SECRET" required:"true" description:"shared secret key"`
@@ -44,6 +45,10 @@ func main() {
SharedSecret: opts.SharedSecret,
Revision: revision,
})
for _, entry := range c.HandleDeprecatedFlags() {
log.Printf("[WARN] --%s is deprecated and will be removed in v%s, please use --%s instead",
entry.Old, entry.RemoveVersion, entry.New)
}
err := c.Execute(args)
if err != nil {
log.Printf("[ERROR] failed with %+v", err)
@@ -79,6 +84,7 @@ func getDump() string {
return string(stacktrace[:length])
}
// nolint:gochecknoinits // can't avoid it in this place
func init() {
// catch SIGQUIT and print stack traces
sigChan := make(chan os.Signal)
+57 -33
View File
@@ -1,21 +1,21 @@
package main
import (
"context"
"fmt"
"io/ioutil"
"math/rand"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
func Test_Main(t *testing.T) {
@@ -24,45 +24,37 @@ func Test_Main(t *testing.T) {
require.NoError(t, err)
defer os.RemoveAll(dir)
port := chooseRandomUnusedPort()
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
"--avatar.fs.path=" + dir, "--port=18222", "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"}
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"}
done := make(chan struct{})
go func() {
time.Sleep(5000 * time.Millisecond)
<-done
e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
require.Nil(t, e)
require.NoError(t, e)
}()
wg := sync.WaitGroup{}
wg.Add(1)
finished := make(chan struct{})
go func() {
st := time.Now()
main()
assert.True(t, time.Since(st).Seconds() >= 5, "should take about 5s")
wg.Done()
close(finished)
}()
var passed bool
err = repeater.NewDefault(10, time.Millisecond*500).Do(context.Background(), func() error {
resp, e := http.Get("http://localhost:18222/api/v1/ping")
if e != nil {
t.Logf("%+v", e)
return e
}
require.Nil(t, e)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, e := ioutil.ReadAll(resp.Body)
assert.Nil(t, e)
assert.Equal(t, "pong", string(body))
passed = true
return nil
})
// defer cleanup because require check below can fail
defer func() {
close(done)
<-finished
}()
waitForHTTPServerStart(port)
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, true, passed, "at least on ping passed")
wg.Wait()
assert.Equal(t, "pong", string(body))
}
func TestGetDump(t *testing.T) {
@@ -70,5 +62,37 @@ func TestGetDump(t *testing.T) {
assert.True(t, strings.Contains(dump, "goroutine"))
assert.True(t, strings.Contains(dump, "[running]"))
assert.True(t, strings.Contains(dump, "backend/app/main.go"))
log.Printf("\n dump: %s", dump)
t.Logf("\n dump: %s", dump)
}
func chooseRandomUnusedPort() (port int) {
for i := 0; i < 10; i++ {
port = 40000 + int(rand.Int31n(10000))
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
_ = ln.Close()
break
}
}
return port
}
func waitForHTTPServerStart(port int) {
// wait for up to 10 seconds for server to start before returning it
client := http.Client{Timeout: time.Second}
for i := 0; i < 100; i++ {
time.Sleep(time.Millisecond * 100)
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
_ = resp.Body.Close()
return
}
}
}
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"),
)
}
+6 -5
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBackup_RemoveOldBackupFiles(t *testing.T) {
@@ -21,17 +22,17 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) {
for i := 1; i <= 10; i++ {
fname := fmt.Sprintf("%s/backup-site1-201712%02d.gz", loc, i)
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
assert.Nil(t, err)
assert.NoError(t, err)
}
fname := fmt.Sprintf("%s/backup-site2-20171210.gz", loc)
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
assert.Nil(t, err)
assert.NoError(t, err)
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3}
bk.removeOldBackupFiles()
ff, err := ioutil.ReadDir(loc)
assert.Nil(t, err)
assert.Equal(t, 4, len(ff), "should keep 4 files - 3 kept for sit1, and one for site2")
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())
assert.Equal(t, "backup-site1-20171209.gz", ff[1].Name())
assert.Equal(t, "backup-site1-20171210.gz", ff[2].Name())
@@ -76,7 +77,7 @@ func TestBackup_Do(t *testing.T) {
type mockExporter struct{}
func (mock *mockExporter) Export(w io.Writer, siteID string) (int, error) {
func (mock *mockExporter) Export(w io.Writer, _ string) (int, error) {
_, err := w.Write([]byte("some export blah blah 1234567890"))
return 1000, err
}
+6 -6
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
@@ -52,9 +52,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)
@@ -136,9 +135,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
+23 -17
View File
@@ -6,29 +6,30 @@ import (
"testing"
"time"
bolt "github.com/coreos/bbolt"
"github.com/stretchr/testify/assert"
"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) {
defer os.Remove("/tmp/remark-test.db")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.Nil(t, err, "create store")
dataStore := service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}
require.NoError(t, err, "create store")
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")
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 4, size)
last, err := dataStore.Last("test", 10)
assert.Nil(t, err)
assert.Equal(t, 4, len(last), "4 comments imported")
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 4, len(last), "4 comments imported")
c := last[len(last)-1] // last reverses, get first one
assert.True(t, strings.HasPrefix(c.Text, "<p>The quick brown fox"))
@@ -38,13 +39,18 @@ 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.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, len(posts), "2 posts")
count, err := dataStore.Count(store.Locator{SiteID: "test", URL: "https://radio-t.com/p/2011/03/05/podcast-229/"})
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, count)
}
@@ -56,7 +62,7 @@ func TestDisqus_Convert(t *testing.T) {
for comment := range ch {
res = append(res, comment)
}
assert.Equal(t, 4, len(res), "4 comments total, 1 spam excluded, 1 bad excluded")
require.Equal(t, 4, len(res), "4 comments total, 1 spam excluded, 1 bad excluded")
exp0 := store.Comment{
ID: "299619020",
@@ -70,6 +76,7 @@ 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])
@@ -168,10 +175,9 @@ var xmlTestDisqus = `<?xml version="1.0" encoding="utf-8"?>
<isDeleted>false</isDeleted>
<isSpam>false</isSpam>
<author>
<email>dmitri.noname@gmail.com</email>
<name>Dmitry Noname</name>
<email>john.nousername@gmail.com</email>
<name>No Username</name>
<isAnonymous>false</isAnonymous>
<username>google-74b9e7568ef6860e93862c5d77590123</username>
</author>
<ipAddress>89.89.89.139</ipAddress>
<thread dsq:id="247918464"/>
+74
View File
@@ -0,0 +1,74 @@
package migrator
import (
"errors"
"io"
"io/ioutil"
"strings"
)
// URLMapper implements Mapper interface
type URLMapper struct {
rules map[string]string
}
// NewURLMapper reads rules from given reader and returns initialized URLMapper
// if given rules are valid.
func NewURLMapper(reader io.Reader) (Mapper, error) {
u := &URLMapper{}
if err := u.loadRules(reader); err != nil {
return u, err
}
return u, nil
}
// loadRules loads url-mapping rules from reader to mapper.
// Rules must be a text consists of rows separated by \n.
// Each row holds from-url and to-url separated by space.
// If urls end with asterisk (*) it means try to match by prefix.
// Example:
// 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)
if err != nil {
return err
}
rulesText := strings.TrimSpace(string(data))
u.rules = make(map[string]string)
for _, row := range strings.Split(rulesText, "\n") {
row = strings.TrimSpace(row)
urls := strings.Split(row, " ")
if len(urls) != 2 {
return errors.New("bad row " + row)
}
from, to := strings.TrimSpace(urls[0]), strings.TrimSpace(urls[1])
u.rules[from] = to
}
return nil
}
// URL maps given url to another url according loaded url-rules.
// If not matched returns given url.
func (u *URLMapper) URL(url string) string {
if newURL, ok := u.rules[url]; ok {
return newURL
}
// try to match by prefix
for oldURL, newURL := range u.rules {
if !strings.HasSuffix(oldURL, "*") {
continue
}
oldURL = strings.TrimSuffix(oldURL, "*")
newURL = strings.TrimSuffix(newURL, "*")
if strings.HasPrefix(url, oldURL) {
return newURL + strings.TrimPrefix(url, oldURL)
}
}
// search failed, return given url
return url
}
+90
View File
@@ -0,0 +1,90 @@
package migrator
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUrlMapper_URL(t *testing.T) {
// want remap urls from https://radio-t.com to https://www.radio-t.com
// also map individual urls
rules := strings.NewReader(`
https://radio-t.com* https://www.radio-t.com*
https://radio-t.com/p/2018/09/22////podcast-616/ https://www.radio-t.com/p/2018/09/22/podcast-616/
https://radio-t.com/p/2018/09/22/podcast-616/?with_query=1 https://www.radio-t.com/p/2018/09/22/podcast-616/
`)
mapper, err := NewURLMapper(rules)
assert.NoError(t, err)
// if url not matched mapper should return given url
assert.Equal(t, "https://any.com/post/1/", mapper.URL("https://any.com/post/1/"))
assert.Equal(t, "https://radio-t.co", mapper.URL("https://radio-t.co"))
// check strict matching
assert.Equal(t, "https://www.radio-t.com/p/2018/09/22/podcast-616/", mapper.URL("https://radio-t.com/p/2018/09/22////podcast-616/"))
assert.Equal(t, "https://www.radio-t.com/p/2018/09/22/podcast-616/", mapper.URL("https://radio-t.com/p/2018/09/22/podcast-616/?with_query=1"))
// check pattern matching (by prefix)
assert.Equal(t, "https://www.radio-t.com/p/post/123/", mapper.URL("https://radio-t.com/p/post/123/"))
// want remap from http to https
rules = strings.NewReader(`http://anysite.com/p/123 https://anysite.com/p/321`)
mapper, err = NewURLMapper(rules)
assert.NoError(t, err)
assert.Equal(t, "https://anysite.com/p/321", mapper.URL("http://anysite.com/p/123"))
assert.Equal(t, "https://notexist", mapper.URL("https://notexist"))
assert.Equal(t, "https://anysite.com/", mapper.URL("https://anysite.com/")) // not exist
// want remap from http to https by pattern
rules = strings.NewReader(`http://anysite.com* https://anysite.com*`)
mapper, err = NewURLMapper(rules)
assert.NoError(t, err)
assert.Equal(t, "https://anysite.com/p/1", mapper.URL("http://anysite.com/p/1"))
assert.Equal(t, "https://anysite.com/", mapper.URL("http://anysite.com/"))
assert.Equal(t, "https://notexist", mapper.URL("https://notexist"))
}
func TestUrlMapper_New(t *testing.T) {
cases := []struct {
rules string
expectError bool
}{
// bad input, expect error
{
rules: "https://radio-t.com ",
expectError: true,
},
{
rules: "https://radio-t.com https://radio-t.com https://radio-t.com",
expectError: true,
},
{
rules: "https://radio-t.com https://radio-t.com\n https://radio-t.com",
expectError: true,
},
{
rules: "https://radio-t.com \n https://radio-t.com https://radio-t.com",
expectError: true,
},
// valid input, no error
{
rules: "https://radio-t.com* https://www.radio-t.com*",
},
{
rules: "https://radio-t.com/p/2018/09/22/podcast-616/?with_query=1 https://www.radio-t.com/p/2018/09/22/podcast-616/",
},
{
rules: "https://any.com/p/111 https://any.com/p/222 \n https://any.com/p/333 https://any.com/p/222 \n",
},
}
for _, c := range cases {
_, err := NewURLMapper(strings.NewReader(c.rules))
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
}
}
+15 -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
@@ -24,10 +24,20 @@ type Exporter interface {
Export(w io.Writer, siteID string) (int, error)
}
// Mapper defines interface to convert data in import procedure
type Mapper interface {
URL(url string) string
}
// MapperMaker defines function that reads rules from reader and
// returns new Mapper with loaded rules. If rules are not valid
// it returns error.
type MapperMaker func(reader io.Reader) (Mapper, error)
// Store defines minimal interface needed to export and import comments
type Store interface {
Create(comment store.Comment) (commentID string, err error)
Find(locator store.Locator, sort string) ([]store.Comment, error)
Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error)
List(siteID string, limit int, skip int) ([]store.PostInfo, error)
DeleteAll(siteID string) error
Metas(siteID string) (umetas []service.UserMetaData, pmetas []service.PostMetaData, err error)
@@ -42,6 +52,8 @@ type ImportParams struct {
SiteID string
}
var adminUser = store.User{Admin: true}
// ImportComments imports from given provider format and saves to store
func ImportComments(p ImportParams) (int, error) {
log.Printf("[INFO] import from %s (%s) to %s", p.InputFile, p.Provider, p.SiteID)
+30 -24
View File
@@ -4,14 +4,16 @@ import (
"io/ioutil"
"os"
"testing"
"time"
bolt "github.com/coreos/bbolt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
"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) {
@@ -21,22 +23,23 @@ func TestMigrator_ImportDisqus(t *testing.T) {
}()
err := ioutil.WriteFile("/tmp/disqus-test.xml", []byte(xmlTestDisqus), 0600)
require.Nil(t, err)
require.NoError(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.Nil(t, err, "create store")
dataStore := &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}
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: "/tmp/disqus-test.xml",
SiteID: "test",
Provider: "disqus",
})
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 4, size)
last, err := dataStore.Last("test", 10)
assert.Nil(t, err)
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
assert.NoError(t, err)
assert.Equal(t, 4, len(last), "4 comments imported")
}
@@ -47,22 +50,23 @@ func TestMigrator_ImportWordPress(t *testing.T) {
}()
err := ioutil.WriteFile("/tmp/wordpress-test.xml", []byte(xmlTestWP), 0600)
require.Nil(t, err)
require.NoError(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
require.Nil(t, err, "create store")
dataStore := &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}
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: "/tmp/wordpress-test.xml",
SiteID: "test",
Provider: "wordpress",
})
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 3, size)
last, err := dataStore.Last("test", 10)
assert.Nil(t, err)
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
assert.NoError(t, err)
assert.Equal(t, 3, len(last), "3 comments imported")
}
@@ -76,11 +80,12 @@ func TestMigrator_ImportNative(t *testing.T) {
`{"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)
require.Nil(t, err)
require.NoError(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "radio-t"})
require.Nil(t, err, "create store")
dataStore := &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}
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,
@@ -88,19 +93,20 @@ func TestMigrator_ImportNative(t *testing.T) {
SiteID: "radio-t",
Provider: "native",
})
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, size)
last, err := dataStore.Last("radio-t", 10)
assert.Nil(t, err)
last, err := dataStore.Last("radio-t", 10, time.Time{}, store.User{})
assert.NoError(t, err)
assert.Equal(t, 2, len(last), "2 comments imported")
}
func TestMigrator_ImportFailed(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.Nil(t, err, "create store")
dataStore := &service.DataStore{Interface: b}
require.NoError(t, err, "create store")
dataStore := &service.DataStore{Engine: b}
defer dataStore.Close()
_, err = ImportComments(ImportParams{
DataStore: dataStore,
InputFile: "/tmp/disqus-test.xml",
+47 -6
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
@@ -49,7 +49,7 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
commentsCount := 0
for i := len(topics) - 1; i >= 0; i-- { // topics from List sorted in opposite direction
topic := topics[i]
comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time")
comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser)
if e != nil {
return commentsCount, e
}
@@ -87,9 +87,49 @@ func (n *Native) exportMeta(siteID string, w io.Writer) (err error) {
return nil
}
// WithMapper wraps reader with url-mapper.
func WithMapper(reader io.Reader, mapper Mapper) io.Reader {
r, w := io.Pipe()
go func() {
var err error
defer func() {
log.Printf("[DEBUG] finish write to pipe with %+v", err)
if e := w.Close(); e != nil {
log.Printf("[WARN] failed close pipe writer with %+v", e)
}
}()
// decode from reader and encode to pipe writer
dec, enc := json.NewDecoder(reader), json.NewEncoder(w)
m := meta{}
if err = dec.Decode(&m); err != nil {
return
}
for i := range m.Posts {
m.Posts[i].URL = mapper.URL(m.Posts[i].URL)
}
if err = enc.Encode(m); err != nil {
return
}
for {
comment := store.Comment{}
if err = dec.Decode(&comment); err != nil {
return
}
comment.Locator.URL = mapper.URL(comment.Locator.URL)
if err = enc.Encode(comment); err != nil {
return
}
}
}()
return r
}
// Import comments from json strings produced by Remark.Export
func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
m := meta{}
dec := json.NewDecoder(reader)
if err = dec.Decode(&m); err != nil {
@@ -100,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
@@ -115,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
}
+98 -50
View File
@@ -4,27 +4,25 @@ import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"time"
bolt "github.com/coreos/bbolt"
log "github.com/go-pkgz/lgr"
"github.com/stretchr/testify/assert"
"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"
)
var testDb = "/tmp/test-remark.db"
func TestNative_Export(t *testing.T) {
defer os.Remove(testDb)
b := prep(t) // write 2 comments
b, teardown := prep(t) // write 2 comments
defer teardown()
assert.NoError(t, b.SetReadOnly(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, true))
assert.NoError(t, b.SetVerified("radio-t", "user1", true))
assert.NoError(t, b.SetBlock("radio-t", "user2", true, time.Hour))
@@ -32,33 +30,33 @@ func TestNative_Export(t *testing.T) {
buf := &bytes.Buffer{}
size, err := r.Export(buf, "radio-t")
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, size)
c1 := buf.String()
log.Print(c1)
t.Log(c1)
dec := json.NewDecoder(strings.NewReader(c1))
meta := struct {
m := struct {
Version int `json:"version"`
Users []service.UserMetaData `json:"users"`
Posts []service.PostMetaData `json:"posts"`
}{}
require.NoError(t, dec.Decode(&meta), "decode meta")
require.NoError(t, dec.Decode(&m), "decode meta")
assert.Equal(t, 2, len(meta.Users))
assert.Equal(t, "user1", meta.Users[0].ID)
assert.Equal(t, false, meta.Users[0].Blocked.Status)
assert.Equal(t, true, meta.Users[0].Verified)
assert.Equal(t, "user2", meta.Users[1].ID)
assert.Equal(t, true, meta.Users[1].Blocked.Status)
assert.Equal(t, false, meta.Users[1].Verified)
require.Equal(t, 2, len(m.Users))
assert.Equal(t, "user1", m.Users[0].ID)
assert.Equal(t, false, m.Users[0].Blocked.Status)
assert.Equal(t, true, m.Users[0].Verified)
assert.Equal(t, "user2", m.Users[1].ID)
assert.Equal(t, true, m.Users[1].Blocked.Status)
assert.Equal(t, false, m.Users[1].Verified)
assert.Equal(t, 1, len(meta.Posts))
assert.Equal(t, "https://radio-t.com", meta.Posts[0].URL)
assert.Equal(t, true, meta.Posts[0].ReadOnly)
require.Equal(t, 1, len(m.Posts))
assert.Equal(t, "https://radio-t.com", m.Posts[0].URL)
assert.Equal(t, true, m.Posts[0].ReadOnly)
comments := [3]store.Comment{}
@@ -70,28 +68,70 @@ func TestNative_Export(t *testing.T) {
}
func TestNative_Import(t *testing.T) {
defer os.Remove(testDb)
b, teardown := prep(t) // write 2 comments
defer teardown()
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 := prep(t) // write some recs
r := Native{DataStore: &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}}
b.AdminStore = admin.NewStaticStore("12345", nil, []string{}, "")
r := Native{DataStore: b}
size, err := r.Import(strings.NewReader(inp), "radio-t")
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, size)
comments, err := b.Last("radio-t", 10)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments))
comments, err := b.Last("radio-t", 10, time.Time{}, store.User{})
assert.NoError(t, err)
require.Equal(t, 2, len(comments))
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"))
assert.Equal(t, true, b.IsBlocked("radio-t", "user2"))
assert.Equal(t, false, b.IsVerified("radio-t", "user2"))
}
func TestNative_ImportWithMapper(t *testing.T) {
b, teardown := prep(t) // write 2 comments
defer teardown()
// want to remap comments to https://rdt.c
rules := `https://radio-t.com* https://rdt.c*`
mapper, err := NewURLMapper(strings.NewReader(rules))
assert.NoError(t, err)
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"}`
mappedReader := WithMapper(strings.NewReader(inp), mapper)
b.AdminStore = admin.NewStaticStore("12345", nil, []string{}, "")
r := Native{DataStore: b}
size, err := r.Import(mappedReader, "radio-t")
assert.NoError(t, err)
assert.Equal(t, 2, size)
comments, err := b.Last("radio-t", 10, time.Time{}, store.User{})
assert.NoError(t, err)
require.Equal(t, 2, len(comments))
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.Equal(t, "https://rdt.c/2", comments[0].Locator.URL)
assert.Equal(t, "efbc17f177ee1a1c0ee6e1e025749966ec071adc", comments[1].ID)
assert.Equal(t, true, b.IsReadOnly(comments[1].Locator))
assert.Equal(t, "https://rdt.c", comments[1].Locator.URL)
assert.Equal(t, false, b.IsBlocked("radio-t", "user1"))
assert.Equal(t, true, b.IsVerified("radio-t", "user1"))
@@ -101,48 +141,53 @@ func TestNative_Import(t *testing.T) {
}
func TestNative_ImportWrongVersion(t *testing.T) {
b, teardown := prep(t) // write 2 comments
defer teardown()
inp := `{"version":2,"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"}`
b := prep(t) // write some recs
r := Native{DataStore: &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}}
b.AdminStore = admin.NewStaticStore("12345", nil, []string{}, "")
r := Native{DataStore: b}
size, err := r.Import(strings.NewReader(inp), "radio-t")
assert.EqualError(t, err, "unexpected import file version 2")
assert.Equal(t, 0, size)
}
func TestNative_ImportManyWithError(t *testing.T) {
defer os.Remove(testDb)
b, teardown := prep(t) // write 2 comments
defer teardown()
goodRec := `{"id":"%d","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"
buf := &bytes.Buffer{}
buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n")
for i := 0; i < 1200; i++ {
for i := 0; i < 100; i++ {
buf.WriteString(fmt.Sprintf(goodRec, i))
}
buf.WriteString("{}\n")
buf.WriteString("{}\n")
b := prep(t) // write some recs
r := Native{DataStore: &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}}
b.AdminStore = admin.NewStaticStore("12345", nil, []string{}, "")
r := Native{DataStore: b}
n, err := r.Import(buf, "radio-t")
assert.EqualError(t, err, "failed to save 2 comments")
assert.Equal(t, 1200, n)
comments, err := b.Find(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}, "time")
assert.Nil(t, err)
assert.Equal(t, 1200, len(comments))
assert.Equal(t, 100, n)
comments, err := b.Find(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}, "time", store.User{})
assert.NoError(t, err)
assert.Equal(t, 100, len(comments))
}
// makes new boltdb, put two records
func prep(t *testing.T) *service.DataStore {
os.Remove(testDb)
func prep(t *testing.T) (ds *service.DataStore, teardown func()) {
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{SiteID: "radio-t", FileName: testDb})
assert.Nil(t, err)
testDB := fmt.Sprintf("/tmp/migrator-%d.db", rand.Intn(999999999))
b := &service.DataStore{Interface: boltStore, AdminStore: admin.NewStaticStore("12345", []string{}, "")}
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{}, "")}
comment := store.Comment{
ID: "efbc17f177ee1a1c0ee6e1e025749966ec071adc",
@@ -152,7 +197,7 @@ func prep(t *testing.T) *service.DataStore {
User: store.User{ID: "user1", Name: "user name"},
}
_, err = b.Create(comment)
assert.Nil(t, err)
assert.NoError(t, err)
comment = store.Comment{
Text: "some text2", Timestamp: time.Date(2017, 12, 20, 15, 18, 23, 0, time.Local),
@@ -160,7 +205,10 @@ func prep(t *testing.T) *service.DataStore {
User: store.User{ID: "user2", Name: "user name"},
}
_, err = b.Create(comment)
assert.Nil(t, err)
assert.NoError(t, err)
return b
return b, func() {
require.NoError(t, b.Close())
_ = os.Remove(testDB)
}
}
+4 -3
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)
@@ -139,6 +139,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++
+21 -17
View File
@@ -6,30 +6,32 @@ import (
"testing"
"time"
bolt "github.com/coreos/bbolt"
"github.com/stretchr/testify/assert"
"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) {
siteID := "testWP"
defer func() { _ = os.Remove("/tmp/remark-test.db") }()
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: siteID})
assert.Nil(t, err, "create store")
assert.NoError(t, err, "create store")
dataStore := service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
defer dataStore.Close()
wp := WordPress{DataStore: &dataStore}
size, err := wp.Import(strings.NewReader(xmlTestWP), siteID)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 3, size)
last, err := dataStore.Last(siteID, 10)
assert.Nil(t, err)
assert.Equal(t, 3, len(last), "3 comments imported")
last, err := dataStore.Last(siteID, 10, time.Time{}, adminUser)
assert.NoError(t, err)
require.Equal(t, 3, len(last), "3 comments imported")
c := last[0]
assert.Equal(t, "14", c.ID)
@@ -40,16 +42,17 @@ 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.Nil(t, err)
assert.Equal(t, 1, len(posts))
assert.NoError(t, err)
require.Equal(t, 1, len(posts))
p := posts[0]
assert.Equal(t, "https://realmenweardress.es/2010/07/do-you-rp/", p.URL)
count, err := dataStore.Count(store.Locator{URL: "https://realmenweardress.es/2010/07/do-you-rp/", SiteID: siteID})
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 3, count)
}
@@ -61,7 +64,7 @@ func TestWordPress_Convert(t *testing.T) {
for c := range ch {
comments = append(comments, c)
}
assert.Equal(t, 3, len(comments), "3 comments exported, 1 excluded")
require.Equal(t, 3, len(comments), "3 comments exported, 1 excluded")
exp1 := store.Comment{
ID: "13",
@@ -75,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])
@@ -88,7 +92,7 @@ func TestWP_Convert_MD(t *testing.T) {
for c := range ch {
comments = append(comments, c)
}
assert.Equal(t, 3, len(comments), "3 comments exported")
require.Equal(t, 3, len(comments), "3 comments exported")
assert.Equal(t, "<p>Row1<br/>\nRow2</p>\n\n<p>Row4</p>\n", comments[0].Text)
@@ -188,7 +192,7 @@ var xmlTestWP = `
<category domain="post_tag" nicename="alts"><![CDATA[alts]]></category>
<category domain="post_tag" nicename="role-playing"><![CDATA[role playing]]></category>
<category domain="category" nicename="stuff"><![CDATA[Stuff]]></category>
<category domain="post_tag" nicename="wierd-in-a-cant-quite-help-myself-way"><![CDATA[wierd in a can't quite help myself way]]></category>
<category domain="post_tag" nicename="weird-in-a-cant-quite-help-myself-way"><![CDATA[weird in a can't quite help myself way]]></category>
<wp:postmeta>
<wp:meta_key><![CDATA[_edit_last]]></wp:meta_key>
<wp:meta_value><![CDATA[2]]></wp:meta_value>
+431
View File
@@ -0,0 +1,431 @@
package notify
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"mime"
"mime/quotedprintable"
"net"
"net/smtp"
"text/template"
"time"
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
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
}
// SMTPParams contain settings for smtp server connection
type SMTPParams struct {
Host string // SMTP host
Port int // SMTP port
TLS bool // TLS auth
Username string // user name
Password string // password
TimeOut time.Duration // TCP connection timeout
}
// Email implements notify.Destination for email
type Email struct {
EmailParams
SMTPParams
smtp smtpClientCreator
msgTmpl *template.Template // parsed request message template
verifyTmpl *template.Template // parsed verification message template
}
// default email client implementation
type emailClient struct{ smtpClientCreator }
// smtpClient interface defines subset of net/smtp used by email client
type smtpClient interface {
Mail(string) error
Auth(smtp.Auth) error
Rcpt(string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}
// smtpClientCreator interface defines function for creating new smtpClients
type smtpClientCreator interface {
Create(SMTPParams) (smtpClient, error)
}
type emailMessage struct {
from string
to string
message string
}
// msgTmplData store data for message from request template execution
type msgTmplData struct {
UserName string
UserPicture string
CommentText string
CommentLink string
CommentDate time.Time
ParentUserName string
ParentUserPicture string
ParentCommentText string
ParentCommentLink string
ParentCommentDate time.Time
PostTitle string
Email string
UnsubscribeLink string
ForAdmin bool
}
// verifyTmplData store data for verification message template execution
type verifyTmplData struct {
User string
Token string
Email string
Site string
SubscribeURL string
}
const (
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}
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)
return &res, nil
}
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) 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.User)
default:
}
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(
ctx,
func() error {
return e.sendMessage(emailMessage{from: e.From, to: req.Email, message: msg})
})
}
// buildVerificationMessage generates verification email message based on given input
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
subject := e.VerificationSubject
msg := bytes.Buffer{}
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
User: user,
Token: token,
Email: email,
Site: site,
SubscribeURL: e.SubscribeURL,
})
if err != nil {
return "", errors.Wrapf(err, "error executing template to build verification message")
}
return e.buildMessage(subject, msg.String(), email, "text/html", "")
}
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
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 %q", req.Comment.PostTitle)
}
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")
}
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
if forAdmin {
unsubscribeLink = ""
}
commentURLPrefix := req.Comment.Locator.URL + uiNav
msg := bytes.Buffer{}
tmplData := msgTmplData{
UserName: req.Comment.User.Name,
UserPicture: req.Comment.User.Picture,
CommentText: req.Comment.Text,
CommentLink: commentURLPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp,
PostTitle: req.Comment.PostTitle,
Email: email,
UnsubscribeLink: unsubscribeLink,
ForAdmin: forAdmin,
}
// in case of message to admin, parent message might be empty
if req.Comment.ParentID != "" {
tmplData.ParentUserName = req.parent.User.Name
tmplData.ParentUserPicture = req.parent.User.Picture
tmplData.ParentCommentText = req.parent.Text
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
tmplData.ParentCommentDate = req.parent.Timestamp
}
err = e.msgTmpl.Execute(&msg, tmplData)
if err != nil {
return "", errors.Wrapf(err, "error executing template to build comment reply message")
}
return e.buildMessage(subject, msg.String(), email, "text/html", unsubscribeLink)
}
// buildMessage generates email message to send using net/smtp.Data()
func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink string) (message string, err error) {
addHeader := func(msg, h, v string) string {
msg += fmt.Sprintf("%s: %s\n", h, v)
return msg
}
message = addHeader(message, "From", e.From)
message = addHeader(message, "To", to)
message = addHeader(message, "Subject", mime.BEncoding.Encode("utf-8", subject))
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
if contentType != "" {
message = addHeader(message, "MIME-version", "1.0")
message = addHeader(message, "Content-Type", contentType+`; charset="UTF-8"`)
}
if unsubscribeLink != "" {
// https://support.google.com/mail/answer/81126 -> "Include option to unsubscribe"
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
message = addHeader(message, "List-Unsubscribe", "<"+unsubscribeLink+">")
}
message = addHeader(message, "Date", time.Now().Format(time.RFC1123Z))
buff := &bytes.Buffer{}
qp := quotedprintable.NewWriter(buff)
if _, err := qp.Write([]byte(body)); err != nil {
return "", err
}
// flush now, must NOT use defer, for small body, defer may cause buff.String() got empty body
if err := qp.Close(); err != nil {
return "", fmt.Errorf("quotedprintable Write failed: %w", err)
}
m := buff.String()
message += "\n" + m
return message, nil
}
// sendMessage sends messages to server in a new connection, closing the connection after finishing.
// Thread safe.
func (e *Email) sendMessage(m emailMessage) error {
if e.smtp == nil {
return errors.New("sendMessage called without client set")
}
client, err := e.smtp.Create(e.SMTPParams)
if err != nil {
return errors.Wrap(err, "failed to make smtp Create")
}
defer func() {
if err = client.Quit(); err != nil {
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
if err = client.Close(); err != nil {
log.Printf("[WARN] can't close smtp connection, %v", err)
}
}
}()
if err = client.Mail(m.from); err != nil {
return errors.Wrapf(err, "bad from address %q", m.from)
}
if err = client.Rcpt(m.to); err != nil {
return errors.Wrapf(err, "bad to address %q", m.to)
}
writer, err := client.Data()
if err != nil {
return errors.Wrap(err, "can't make email writer")
}
defer func() {
if err = writer.Close(); err != nil {
log.Printf("[WARN] can't close smtp body writer, %v", err)
}
}()
buf := bytes.NewBufferString(m.message)
if _, err = buf.WriteTo(writer); err != nil {
return errors.Wrapf(err, "failed to send email body to %q", m.to)
}
return nil
}
// String representation of Email object
func (e *Email) String() string {
return fmt.Sprintf("email: from %q with username '%s' at server %s:%d", e.From, e.Username, e.Host, e.Port)
}
// Create establish SMTP connection with server using credentials in smtpClientWithCreator.SMTPParams
// and returns pointer to it. Thread safe.
func (s *emailClient) Create(params SMTPParams) (smtpClient, error) {
authenticate := func(c *smtp.Client) error {
if params.Username == "" || params.Password == "" {
return nil
}
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
if err := c.Auth(auth); err != nil {
return errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
}
return nil
}
var c *smtp.Client
srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
if params.TLS {
tlsConf := &tls.Config{
InsecureSkipVerify: false,
ServerName: params.Host,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", srvAddress, tlsConf)
if err != nil {
return nil, errors.Wrapf(err, "failed to dial smtp tls to %s", srvAddress)
}
if c, err = smtp.NewClient(conn, params.Host); err != nil {
return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress)
}
return c, authenticate(c)
}
conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut)
if err != nil {
return nil, errors.Wrapf(err, "timeout connecting to %s", srvAddress)
}
c, err = smtp.NewClient(conn, params.Host)
if err != nil {
return nil, errors.Wrap(err, "failed to dial")
}
return c, authenticate(c)
}
+447
View File
@@ -0,0 +1,447 @@
package notify
import (
"bytes"
"context"
"errors"
"io"
"net/smtp"
"sync"
"testing"
"text/template"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
)
func TestEmailNew(t *testing.T) {
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
errText string
emailParams EmailParams
}{
{
name: "with wrong path to verification template",
errText: "can't read verification template: open notfount.tmpl: no such file or directory",
emailParams: EmailParams{
VerificationTemplatePath: "notfount.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
},
},
{
name: "with wrong path to message template",
errText: "can't read message template: open notfount.tmpl: no such file or directory",
emailParams: EmailParams{
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "notfount.tmpl",
},
},
{
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) {
e := Email{EmailParams: d.emailParams}
err := e.setTemplates()
require.Error(t, err)
assert.Contains(t, err.Error(), d.errText)
})
}
}
func TestEmailSendErrors(t *testing.T) {
var err error
e := Email{}
e.TokenGenFn = TokenGenFn
e.verifyTmpl, err = template.New("test").Parse("{{.Test}}")
assert.NoError(t, err)
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.msgTmpl, err = template.New("test").Parse("{{.Test}}")
assert.NoError(t, err)
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\": "+
"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"}}, Emails: []string{"bad@example.org"}}),
"sending email messages about comment \"999\" aborted due to canceled context")
e.smtp = &fakeTestSMTP{}
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad@example.org"}}),
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\":"+
" error creating token for unsubscribe link: token generation error\n\n")
}
func TestEmailSend_ExitConditions(t *testing.T) {
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 Emails and AdminEmails is not sent and returns nil")
}
func TestEmailSendClientError(t *testing.T) {
var testSet = []struct {
name string
smtp *fakeTestSMTP
err string
}{
{name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}},
err: "bad from address \"\": failed to verify sender"},
{name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}},
err: "bad to address \"\": failed to verify receiver"},
{name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}}},
{name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}},
err: "can't make email writer: failed to send"},
}
for _, d := range testSet {
d := d
t.Run(d.name, func(t *testing.T) {
e := Email{smtp: d.smtp}
if d.err != "" {
assert.EqualError(t, e.sendMessage(emailMessage{}), d.err,
"expected error for e.sendMessage")
} else {
assert.NoError(t, e.sendMessage(emailMessage{}),
"expected no error for e.sendMessage")
}
})
}
e := Email{}
e.smtp = nil
assert.Error(t, e.sendMessage(emailMessage{}),
"nil e.smtp should return error")
e.smtp = &fakeTestSMTP{}
assert.NoError(t, e.sendMessage(emailMessage{}), "",
"no error expected for e.sendMessage in normal flow")
e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}}
assert.NoError(t, e.sendMessage(emailMessage{}), "",
"no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close")
e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}}
assert.EqualError(t, e.sendMessage(emailMessage{}), "failed to make smtp Create: failed to create client",
"e.send called without smtpClient set returns error")
}
func TestEmail_DefaultTemplates(t *testing.T) {
email, err := NewEmail(EmailParams{}, SMTPParams{})
assert.Error(t, err)
assert.Nil(t, email)
email, err = NewEmail(EmailParams{VerificationTemplatePath: "testdata/verification.html.tmpl"}, SMTPParams{})
assert.Error(t, err)
assert.Nil(t, email)
}
func TestEmail_Send(t *testing.T) {
email, err := NewEmail(EmailParams{
From: "from@example.org",
VerificationTemplatePath: "testdata/verification.html.tmpl",
MsgTemplatePath: "testdata/msg.html.tmpl",
}, SMTPParams{})
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{
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"}},
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.Emails[0], false)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
Subject: New reply to your comment for "test_title"
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
List-Unsubscribe-Post: List-Unsubscribe=One-Click
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
Date: `)
// 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"},
Emails: []string{"test@example.org"},
}
assert.NoError(t, email.Send(context.TODO(), req))
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
assert.Equal(t, 3, fakeSMTP.readQuitCount(), "plus two emails: one for user and one for admin")
assert.Equal(t, "admin@example.org", fakeSMTP.readRcpt())
res, err = email.buildMessageFromRequest(req, email.AdminEmails[0], true)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: admin@example.org
Subject: New comment to your site for "test_title"
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
}
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{
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"},
}
// 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())
// 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
Subject: Email verification
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
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.User, req.Email, req.Token, req.SiteID)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
Subject: Email verification
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
assert.Contains(t, res, `https://example.org/subscribe.html?token=3Dsecret_`)
}
func Test_emailClient_Create(t *testing.T) {
creator := emailClient{}
client, err := creator.Create(SMTPParams{})
assert.Error(t, err, "absence of address to connect results in error")
assert.Nil(t, client, "no client returned in case of error")
}
type fakeTestSMTP struct {
fail map[string]bool
buff bytes.Buffer
mail, rcpt string
auth bool
close bool
quitCount int
lock sync.RWMutex
}
func (f *fakeTestSMTP) Create(SMTPParams) (smtpClient, error) {
if f.fail["create"] {
return nil, errors.New("failed to create client")
}
return f, nil
}
func (f *fakeTestSMTP) Auth(smtp.Auth) error { f.auth = true; return nil }
func (f *fakeTestSMTP) Mail(m string) error {
f.lock.Lock()
f.mail = m
f.lock.Unlock()
if f.fail["mail"] {
return errors.New("failed to verify sender")
}
return nil
}
func (f *fakeTestSMTP) Rcpt(r string) error {
f.lock.Lock()
f.rcpt = r
f.lock.Unlock()
if f.fail["rcpt"] {
return errors.New("failed to verify receiver")
}
return nil
}
func (f *fakeTestSMTP) Quit() error {
f.lock.Lock()
f.quitCount++
f.lock.Unlock()
if f.fail["quit"] {
return errors.New("failed to quit")
}
return nil
}
func (f *fakeTestSMTP) Close() error {
f.close = true
if f.fail["close"] {
return errors.New("failed to close")
}
return nil
}
func (f *fakeTestSMTP) Data() (io.WriteCloser, error) {
if f.fail["data"] {
return nil, errors.New("failed to send")
}
return nopCloser{&f.buff}, nil
}
func (f *fakeTestSMTP) readRcpt() string {
f.lock.RLock()
defer f.lock.RUnlock()
return f.rcpt
}
func (f *fakeTestSMTP) readMail() string {
f.lock.RLock()
defer f.lock.RUnlock()
return f.mail
}
func (f *fakeTestSMTP) readQuitCount() int {
f.lock.RLock()
defer f.lock.RUnlock()
return f.quitCount
}
func TokenGenFn(user, _, _ string) (string, error) {
if user == "error" {
return "", errors.New("token generation error")
}
return "token", nil
}
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error {
return nil
}
+121 -34
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,17 +27,29 @@ 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) (store.Comment, error)
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
GetUserEmail(siteID string, userID string) (string, error)
}
type request struct {
comment store.Comment
parent store.Comment
// Request notification for a Comment
type Request struct {
Comment store.Comment
parent store.Comment
Emails []string
}
// VerificationRequest notification for user
type VerificationRequest struct {
SiteID string
User string
Email string // if set, send email only
Token string
}
const defaultQueueSize = 100
@@ -49,11 +62,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()
@@ -62,21 +76,54 @@ func NewService(dataService Store, size int, destinations ...Destination) *Servi
return &res
}
// Submit comment to internal channel if not busy, drop if can't send
func (s *Service) Submit(comment store.Comment) {
// Submit Request to internal channel if not busy, drop if can't send
func (s *Service) Submit(req Request) {
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
return
}
parentComment := store.Comment{}
if s.dataService != nil {
if p, err := s.dataService.Get(comment.Locator, comment.ParentID); err == nil {
parentComment = p
if s.dataService != nil && req.Comment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil {
req.parent = p
req.Emails = deduplicateStrings(s.getNotificationEmails(req, p))
}
}
select {
case s.queue <- request{comment: comment, parent: parentComment}:
case s.queue <- req:
default:
log.Printf("[WARN] can't send comment notification to queue, %+v", comment)
log.Printf("[WARN] can't send notification to queue, %+v", req.Comment)
}
}
// getNotificationEmails returns list of emails for notifications for provided comment.
// Emails is not added to the returned list in case original message is from the same user as the notification receiver.
func (s *Service) getNotificationEmails(req Request, notifyComment store.Comment) (result []string) {
// add current user email only if the user is not the one who wrote the original comment
if notifyComment.User.ID != req.Comment.User.ID {
email, err := s.dataService.GetUserEmail(req.Comment.Locator.SiteID, notifyComment.User.ID)
if err != nil {
log.Printf("[WARN] can't read email for %s, %v", notifyComment.User.ID, err)
}
if email != "" {
result = append(result, email)
}
}
if notifyComment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, notifyComment.ParentID, store.User{}); err == nil {
result = append(result, s.getNotificationEmails(req, p)...)
}
}
return 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)
}
}
@@ -85,6 +132,7 @@ func (s *Service) Close() {
if s.queue != nil {
log.Print("[DEBUG] close notifier")
close(s.queue)
close(s.verificationQueue)
s.cancel()
<-s.ctx.Done()
}
@@ -92,21 +140,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
}
+69
View File
@@ -0,0 +1,69 @@
package notify
import (
"context"
"fmt"
"sync"
"time"
log "github.com/go-pkgz/lgr"
)
// MockDest is a destination mock
type MockDest struct {
data []Request
verificationData []VerificationRequest
id int
closed bool
lock sync.Mutex
}
// Send mock
func (m *MockDest) Send(ctx context.Context, r Request) error {
m.lock.Lock()
defer m.lock.Unlock()
select {
case <-time.After(10 * time.Millisecond):
m.data = append(m.data, r)
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
case <-ctx.Done():
log.Printf("ctx closed %d", m.id)
m.closed = true
}
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()
defer m.lock.Unlock()
res := make([]Request, len(m.data))
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) }
+213 -70
View File
@@ -1,90 +1,120 @@
package notify
import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
"sync/atomic"
"testing"
"time"
log "github.com/go-pkgz/lgr"
"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) {
s := NewService(nil, 1)
s := NewService(nil, 0)
assert.Equal(t, defaultQueueSize, cap(s.queue))
assert.NotNil(t, s)
s.Submit(store.Comment{ID: "123"})
s.Submit(store.Comment{ID: "123"})
s.Submit(store.Comment{ID: "123"})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Close()
}
func TestService_WithDestinations(t *testing.T) {
d1, d2 := &mockDest{id: 1}, &mockDest{id: 2}
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
s.Submit(store.Comment{ID: "100"})
s.Submit(Request{Comment: store.Comment{ID: "100"}})
time.Sleep(time.Millisecond * 110)
s.Submit(store.Comment{ID: "101"})
s.Submit(Request{Comment: store.Comment{ID: "101"}})
time.Sleep(time.Millisecond * 110)
s.Submit(store.Comment{ID: "102"})
s.Submit(Request{Comment: store.Comment{ID: "102"}})
time.Sleep(time.Millisecond * 110)
s.Close()
assert.Equal(t, 3, len(d1.get()), "got all comments to d1")
assert.Equal(t, 3, len(d2.get()), "got all comments to d2")
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
assert.Equal(t, "100", d1.get()[0].comment.ID)
assert.Equal(t, "101", d1.get()[1].comment.ID)
assert.Equal(t, "102", d1.get()[2].comment.ID)
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
}
func TestService_WithDrops(t *testing.T) {
d1, d2 := &mockDest{id: 1}, &mockDest{id: 2}
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
s.Submit(store.Comment{ID: "100"})
s.Submit(store.Comment{ID: "101"})
time.Sleep(time.Millisecond * 110)
s.Submit(store.Comment{ID: "102"})
time.Sleep(time.Millisecond * 110)
s.Submit(Request{Comment: store.Comment{ID: "100"}})
s.Submit(Request{Comment: store.Comment{ID: "101"}})
s.Submit(Request{Comment: store.Comment{ID: "102"}})
time.Sleep(time.Millisecond * 21)
s.Close()
s.Submit(store.Comment{ID: "111"}) // safe to send after close
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
assert.Equal(t, 2, len(d1.get()), "one comment dropped from d1")
assert.Equal(t, 2, len(d2.get()), "one comment dropped from d2")
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) {
d1, d2 := &mockDest{id: 1}, &mockDest{id: 2}
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 5, d1, d2)
assert.NotNil(t, s)
for i := 0; i < 10; i++ {
s.Submit(store.Comment{ID: fmt.Sprintf("%d", 100+i)})
time.Sleep(time.Millisecond * time.Duration(rand.Int31n(200)))
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(d2.get()), "some comments dropped from d2")
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) {
dest := &mockDest{id: 1}
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}}
dataStore.data["p1"] = store.Comment{ID: "p1"}
@@ -93,63 +123,176 @@ func TestService_WithParent(t *testing.T) {
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
s.Submit(store.Comment{ID: "c1", ParentID: "p1"})
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
time.Sleep(time.Millisecond * 110)
s.Submit(store.Comment{ID: "c11", ParentID: "p11"})
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
time.Sleep(time.Millisecond * 110)
s.Close()
destRes := dest.get()
assert.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p1", destRes[0].comment.ParentID)
destRes := dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
assert.Equal(t, "p1", destRes[0].parent.ID)
assert.Equal(t, "p11", destRes[1].comment.ParentID)
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
assert.Equal(t, "", destRes[1].parent.ID)
}
func TestService_EmailRetrieval(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, emailData: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.emailData["u1"] = "u1@example.com"
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{}, emailData: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
dataStore.emailData["u1"] = "u1@example.com"
// second comment goes without email address for notification
dataStore.emailData["u3"] = "u3@example.com"
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(store.Comment{})
s.Submit(Request{Comment: store.Comment{}})
s.Close()
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
}
type mockDest struct {
data []request
id int
closed bool
lock sync.Mutex
type mockStore struct {
data map[string]store.Comment
emailData map[string]string
}
func (m *mockDest) Send(ctx context.Context, r request) error {
m.lock.Lock()
defer m.lock.Unlock()
select {
case <-time.After(100 * time.Millisecond):
m.data = append(m.data, r)
log.Printf("sent %s -> %d", r.comment.ID, m.id)
case <-ctx.Done():
log.Printf("ctx closed %d", m.id)
m.closed = true
}
return nil
}
func (m *mockDest) get() []request {
m.lock.Lock()
defer m.lock.Unlock()
res := make([]request, len(m.data))
copy(res, m.data)
return res
}
func (m *mockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
type mockStore struct{ data map[string]store.Comment }
func (m *mockStore) Get(_ store.Locator, id string) (store.Comment, error) {
func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) {
res, ok := m.data[id]
if !ok {
return store.Comment{}, errors.New("no such id")
}
return res, nil
}
func (m mockStore) GetUserEmail(_, userID string) (string, error) {
email, ok := m.emailData[userID]
if !ok {
return "", errors.New("no such user")
}
return email, nil
}
+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
}
+25 -11
View File
@@ -8,6 +8,7 @@ import (
"html"
"net/http"
"strconv"
"strings"
"time"
log "github.com/go-pkgz/lgr"
@@ -27,8 +28,7 @@ const telegramTimeOut = 5000 * time.Millisecond
const telegramAPIPrefix = "https://api.telegram.org/bot"
// NewTelegram makes telegram bot for notifications
func NewTelegram(token string, channelID string, timeout time.Duration, api string) (*Telegram, error) {
func NewTelegram(token, channelID string, timeout time.Duration, api string) (*Telegram, error) {
if _, err := strconv.ParseInt(channelID, 10, 64); err != nil {
channelID = "@" + channelID // if channelID not a number enforce @ prefix
}
@@ -40,7 +40,7 @@ func NewTelegram(token string, channelID string, timeout time.Duration, api stri
if res.timeout == 0 {
res.timeout = telegramTimeOut
}
log.Printf("[DEBUG] create new telegram notifier for cham %s, timeout=%s, api=%s", channelID, res.timeout, res.timeout)
log.Printf("[DEBUG] create new telegram notifier for chan %s, timeout=%s, api=%s", channelID, res.timeout, res.timeout)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -85,23 +85,23 @@ func NewTelegram(token string, channelID string, timeout time.Duration, api stri
}
// Send to telegram channel
func (t *Telegram) Send(ctx context.Context, req request) error {
func (t *Telegram) Send(ctx context.Context, req Request) error {
client := http.Client{Timeout: telegramTimeOut}
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.comment.ID)
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.Comment.ID)
from := req.comment.User.Name
if req.comment.ParentID != "" {
from := req.Comment.User.Name
if req.Comment.ParentID != "" {
from += " → " + req.parent.User.Name
}
from = "*" + from + "*"
link := fmt.Sprintf("↦ [original comment](%s)", req.comment.Locator.URL+uiNav+req.comment.ID)
if req.comment.PostTitle != "" {
link = fmt.Sprintf("↦ [%s](%s)", req.comment.PostTitle, req.comment.Locator.URL+uiNav+req.comment.ID)
link := fmt.Sprintf("↦ [original comment](%s)", req.Comment.Locator.URL+uiNav+req.Comment.ID)
if req.Comment.PostTitle != "" {
link = fmt.Sprintf("↦ [%s](%s)", t.escapeTitle(req.Comment.PostTitle), req.Comment.Locator.URL+uiNav+req.Comment.ID)
}
u := fmt.Sprintf("%s%s/sendMessage?chat_id=%s&parse_mode=Markdown&disable_web_page_preview=true",
t.apiPrefix, t.token, t.channelID)
msg := fmt.Sprintf("%s\n\n%s\n\n%s", from, req.comment.Orig, link)
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"`
@@ -143,6 +143,20 @@ func (t *Telegram) Send(ctx context.Context, req request) error {
return nil
}
func (t *Telegram) escapeTitle(title string) string {
escSymbols := []string{"[", "]", "(", ")"}
res := title
for _, esc := range escSymbols {
res = strings.Replace(res, esc, "\\"+esc, -1)
}
return res
}
// SendVerification is not implemented for telegram
func (t *Telegram) SendVerification(_ context.Context, _ VerificationRequest) error {
return nil
}
func (t *Telegram) String() string {
return "telegram: " + t.channelID
}
+54 -10
View File
@@ -4,14 +4,15 @@ import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"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) {
@@ -30,14 +31,16 @@ func TestTelegram_New(t *testing.T) {
assert.True(t, time.Since(st) >= 250*5*time.Millisecond)
_, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
assert.NotNil(t, err)
assert.Error(t, err)
assert.Contains(t, err.Error(), "can't decode response:")
_, err = NewTelegram("404", "remark_test", 2*time.Second, ts.URL+"/")
assert.EqualError(t, err, "unexpected telegram status code 404")
_, err = NewTelegram("no-such-thing", "remark_test", 2*time.Second, "http://127.0.0.1:4321/")
assert.EqualError(t, err, "can't initialize telegram notifications: Get http://127.0.0.1:4321/no-such-thing/getMe: dial tcp 127.0.0.1:4321: connect: connection refused")
require.Error(t, err)
assert.Contains(t, err.Error(), "can't initialize telegram notifications")
assert.Contains(t, err.Error(), "dial tcp 127.0.0.1:4321: connect: connection refused")
_, err = NewTelegram("good-token", "remark_test", 2*time.Second, "")
assert.Error(t, err, "empty api url not allowed")
@@ -58,26 +61,44 @@ func TestTelegram_Send(t *testing.T) {
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
c := store.Comment{Text: "some text", ParentID: "1"}
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})
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})
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 = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
assert.NotNil(t, err, "should failed")
err = tb.Send(context.TODO(), request{comment: c, parent: cp})
require.NotNil(t, err)
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.Equal(t, "telegram: @remark_test", tb.String())
}
func TestTelegram_SendVerification(t *testing.T) {
ts := mockTelegramServer()
defer ts.Close()
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
err = tb.SendVerification(context.TODO(), VerificationRequest{})
assert.NoError(t, err)
}
func mockTelegramServer() *httptest.Server {
router := chi.NewRouter()
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
@@ -120,3 +141,26 @@ func mockTelegramServer() *httptest.Server {
return httptest.NewServer(router)
}
func TestTelegram_escapeTitle(t *testing.T) {
tbl := []struct {
inp string
out string
}{
{"", ""},
{"something 123", "something 123"},
{"something [123]", "something \\[123\\]"},
{"something (123)", "something \\(123\\)"},
{"something (123) [aaa]", "something \\(123\\) \\[aaa\\]"},
}
tb := Telegram{}
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
assert.Equal(t, tt.out, tb.escapeTitle(tt.inp))
})
}
}
+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}}
+40 -51
View File
@@ -6,27 +6,42 @@ 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/go-pkgz/rest/cache"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/store"
"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/engine"
)
// admin provides router for all requests available for admin users only
type admin struct {
dataService *service.DataStore
cache cache.LoadingCache
dataService adminStore
cache LoadingCache
authenticator *auth.Service
readOnlyAge int
migrator *Migrator
}
type adminStore interface {
Delete(locator store.Locator, commentID string, mode store.DeleteMode) error
DeleteUser(siteID string, userID string, mode store.DeleteMode) error
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
IsBlocked(siteID string, userID string) bool
SetBlock(siteID string, userID string, status bool, ttl time.Duration) error
BlockedUsers(siteID string) ([]store.BlockedUser, error)
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
SetTitle(locator store.Locator, commentID string) (comment store.Comment, err error)
SetVerified(siteID string, userID string, status bool) error
SetReadOnly(locator store.Locator, status bool) error
SetPin(locator store.Locator, commentID string, status bool) error
}
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
@@ -51,7 +66,7 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
if err := a.dataService.DeleteUser(siteID, userID); err != nil {
if err := a.dataService.DeleteUser(siteID, userID, store.HardDelete); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete user", rest.ErrInternal)
return
}
@@ -67,7 +82,7 @@ func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
ucomments, err := a.dataService.User(siteID, userID, 1, 0)
ucomments, err := a.dataService.User(siteID, userID, 1, 0, rest.GetUserOrEmpty(r))
if err != nil || len(ucomments) == 0 {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get user info", rest.ErrInternal)
return
@@ -76,7 +91,7 @@ func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, ucomments[0].User)
}
// GET /deleteme?token=jwt - delete all user comments by user's request. Gets info about deleted used from provided token
// GET /deleteme?token=jwt - delete all user comments and details by user's request. Gets info about deleted used from provided token
// request made GET to allow direct click from the email sent by user
func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
@@ -96,7 +111,13 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
return
}
if err = a.dataService.DeleteUser(claims.Audience, claims.User.ID); err != nil {
if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.UserEmail); err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
return
}
if err = a.dataService.DeleteUser(claims.Audience, claims.User.ID, store.HardDelete); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user", rest.ErrNoAccess)
return
}
@@ -131,6 +152,13 @@ func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set blocking status", rest.ErrActionRejected)
return
}
// delete comments for permanently blocked user.
if blockStatus && ttl == time.Duration(0) {
if err := a.dataService.DeleteUser(siteID, userID, store.SoftDelete); err != nil {
log.Printf("[WARN] can't delete comments for blocked user %s on site %s, %v", userID, siteID, err)
}
}
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
render.JSON(w, r, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
}
@@ -138,7 +166,7 @@ func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
// GET /blocked?site=siteID - list blocked users
func (a *admin) blockedUsersCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
users, err := a.dataService.Blocked(siteID)
users, err := a.dataService.BlockedUsers(siteID)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get blocked users", rest.ErrSiteNotFound)
return
@@ -218,42 +246,3 @@ func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL))
render.JSON(w, r, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
}
func (a *admin) checkBlocked(siteID string, user store.User) bool {
return a.dataService.IsBlocked(siteID, user.ID)
}
// post-processes comments, hides text of all comments for blocked users,
// resets score and votes too. Also hides sensitive info for non-admin users
func (a *admin) alterComments(comments []store.Comment, r *http.Request) (res []store.Comment) {
res = make([]store.Comment, len(comments))
user, err := rest.GetUserInfo(r)
isAdmin := err == nil && user.Admin
for i, c := range comments {
blocked := a.dataService.IsBlocked(c.Locator.SiteID, c.User.ID)
// process blocked users
if blocked {
if !isAdmin { // reset comment to deleted for non-admins
c.SetDeleted(store.SoftDelete)
}
c.User.Blocked = true
c.Deleted = true
}
// set verified status retroactively
if !blocked {
c.User.Verified = a.dataService.IsVerified(c.Locator.SiteID, c.User.ID)
}
// hide info from non-admins
if !isAdmin {
c.User.IP = ""
}
res[i] = c
}
return res
}
+279 -197
View File
@@ -13,16 +13,15 @@ import (
"testing"
"time"
jwt "github.com/dgrijalva/jwt-go"
"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/go-pkgz/rest/cache"
"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) {
@@ -30,79 +29,82 @@ func TestAdmin_Delete(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1", User: store.User{ID: "id", Name: "name"},
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
c2 := store.Comment{Text: "test test #2", User: store.User{ID: "id", Name: "name"}, ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
id1 := addComment(t, c1, ts)
addComment(t, c2, ts)
// check last comments
res, code := get(t, ts.URL+"/api/v1/last/2?site=radio-t")
res, code := get(t, ts.URL+"/api/v1/last/2?site=remark42")
assert.Equal(t, 200, code)
comments := []store.Comment{}
err := json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, len(comments), "should have 2 comments")
// check multi count
resp, err := post(t, ts.URL+"/api/v1/counts?site=radio-t", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
assert.Nil(t, err)
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bb, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
j := []store.PostInfo{}
err = json.Unmarshal(bb, &j)
assert.Nil(t, err)
assert.Equal(t, []store.PostInfo([]store.PostInfo{{URL: "https://radio-t.com/blah", Count: 2},
{URL: "https://radio-t.com/blah2", Count: 0}}), j)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah", Count: 2},
{URL: "https://radio-t.com/blah2", Count: 0}}, j)
// delete a comment
req, err := http.NewRequest(http.MethodDelete,
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=radio-t&url=https://radio-t.com/blah", ts.URL, id1), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), nil)
require.NoError(t, err)
requireAdminOnly(t, req)
resp, err = sendReq(t, req, adminUmputunToken)
assert.Nil(t, err)
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=radio-t&url=https://radio-t.com/blah", ts.URL, id1))
body, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, 200, code)
cr := store.Comment{}
err = json.Unmarshal([]byte(body), &cr)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "", cr.Text)
assert.True(t, cr.Deleted)
time.Sleep(250 * time.Millisecond)
// check last comments updated
res, code = get(t, ts.URL+"/api/v1/last/2?site=radio-t")
res, code = get(t, ts.URL+"/api/v1/last/2?site=remark42")
assert.Equal(t, 200, code)
comments = []store.Comment{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 1, len(comments), "should have 1 comments")
// check count updated
res, code = get(t, ts.URL+"/api/v1/count?site=radio-t&url=https://radio-t.com/blah")
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
assert.Equal(t, 200, code)
b := map[string]interface{}{}
err = json.Unmarshal([]byte(res), &b)
assert.Nil(t, err)
assert.NoError(t, err)
t.Logf("%#v", b)
assert.Equal(t, 1.0, b["count"], "should report 1 comments")
// check multi count updated
resp, err = post(t, ts.URL+"/api/v1/counts?site=radio-t", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
assert.Nil(t, err)
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)
assert.Nil(t, err)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
j = []store.PostInfo{}
err = json.Unmarshal(bb, &j)
assert.Nil(t, err)
assert.Equal(t, []store.PostInfo([]store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1},
{URL: "https://radio-t.com/blah2", Count: 0}}), j)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1},
{URL: "https://radio-t.com/blah2", Count: 0}}, j)
}
func TestAdmin_Title(t *testing.T) {
@@ -126,26 +128,27 @@ func TestAdmin_Title(t *testing.T) {
defer tss.Close()
c1 := store.Comment{Text: "test test #1", User: store.User{ID: "id", Name: "name"},
Locator: store.Locator{SiteID: "radio-t", URL: tss.URL + "/post1"}}
Locator: store.Locator{SiteID: "remark42", URL: tss.URL + "/post1"}}
c2 := store.Comment{Text: "test test #2", User: store.User{ID: "id", Name: "name"}, ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: tss.URL + "/post2"}}
Locator: store.Locator{SiteID: "remark42", URL: tss.URL + "/post2"}}
id1 := addComment(t, c1, ts)
addComment(t, c2, ts)
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/title/%s?site=radio-t&url=%s/post1", ts.URL, id1, tss.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), nil)
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=radio-t&url=%s/post1", ts.URL, id1, tss.URL))
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL))
require.Equal(t, 200, code)
cr := store.Comment{}
err = json.Unmarshal([]byte(body), &cr)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "post1 blah 123", cr.PostTitle)
}
@@ -154,11 +157,11 @@ func TestAdmin_DeleteUser(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1", Orig: "o test test #1", User: store.User{ID: "id1", Name: "name"},
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
c2 := store.Comment{Text: "test test #2", Orig: "o test test #2", User: store.User{ID: "id2", Name: "name"}, ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
c3 := store.Comment{Text: "test test #3", Orig: "o test test #3", User: store.User{ID: "id2", Name: "name"}, ParentID: "",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
// write comments directly to store to keep user id
id1, err := srv.DataService.Create(c1)
@@ -168,20 +171,21 @@ 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=radio-t", ts.URL, "id2"), nil)
assert.Nil(t, err)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), nil)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
assert.Nil(t, err)
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
res, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time")
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
cmntWithInfo := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &cmntWithInfo)
assert.Nil(t, err)
assert.Equal(t, 3, len(cmntWithInfo.Comments), "should have 3 comment")
assert.NoError(t, err)
require.Equal(t, 3, len(cmntWithInfo.Comments), "should have 3 comment")
// id1 comment untouched
assert.Equal(t, id1, cmntWithInfo.Comments[0].ID)
@@ -206,9 +210,9 @@ func TestAdmin_Pin(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
id1 := addComment(t, c1, ts)
addComment(t, c2, ts)
@@ -216,32 +220,33 @@ 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=radio-t&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/pin/%s?site=remark42&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), nil)
assert.NoError(t, err)
requireAdminOnly(t, req)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
return resp.StatusCode
}
code := pin(1)
assert.Equal(t, 200, code)
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", ts.URL, id1))
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, 200, code)
cr := store.Comment{}
err := json.Unmarshal([]byte(body), &cr)
assert.Nil(t, err)
assert.NoError(t, err)
assert.True(t, cr.Pin)
code = pin(-1)
assert.Equal(t, 200, code)
body, code = get(t, fmt.Sprintf("%s/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", ts.URL, id1))
body, code = get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
assert.Equal(t, 200, code)
cr = store.Comment{}
err = json.Unmarshal([]byte(body), &cr)
assert.Nil(t, err)
assert.NoError(t, err)
assert.False(t, cr.Pin)
}
@@ -249,191 +254,244 @@ func TestAdmin_Block(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
makeTwoComments := func() {
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
_, err := srv.DataService.Create(c1)
assert.Nil(t, err)
_, err = srv.DataService.Create(c2)
assert.Nil(t, err)
_, err := srv.DataService.Create(c1)
require.NoError(t, err)
_, err = srv.DataService.Create(c2)
require.NoError(t, err)
}
block := func(val int, ttl string) (code int, body []byte) {
url := fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d", ts.URL, "user1", val)
url := fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", val)
if ttl != "" {
url = url + "&ttl=" + ttl
}
req, e := http.NewRequest(http.MethodPut, url, nil)
assert.Nil(t, e)
req, err := http.NewRequest(http.MethodPut, url, nil)
assert.NoError(t, err)
requireAdminOnly(t, req)
resp, e := sendReq(t, req, adminUmputunToken)
require.Nil(t, e)
body, e = ioutil.ReadAll(resp.Body)
assert.Nil(t, e)
require.Nil(t, resp.Body.Close())
resp, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
body, err = ioutil.ReadAll(resp.Body)
assert.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode, body
}
makeTwoComments()
// block permanently
code, body := block(1, "")
require.Equal(t, 200, code)
j := R.JSON{}
err = json.Unmarshal(body, &j)
assert.Nil(t, err)
err := json.Unmarshal(body, &j)
assert.NoError(t, err)
assert.Equal(t, "user1", j["user_id"])
assert.Equal(t, true, j["block"])
assert.Equal(t, "radio-t", j["site_id"])
assert.Equal(t, "remark42", j["site_id"])
res, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time")
assert.True(t, srv.adminRest.dataService.IsBlocked("remark42", "user1"))
assert.False(t, srv.adminRest.dataService.IsBlocked("remark42", "user2"))
// get last to confirm one comment deleted
bodyStr, code := get(t, ts.URL+"/api/v1/last/10?site=remark42")
assert.Equal(t, 200, code)
pi := []store.PostInfo{}
assert.NoError(t, json.Unmarshal([]byte(bodyStr), &pi))
assert.Equal(t, 1, len(pi), "last status updated, one comment left")
// check if count call has one comment left
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)
assert.NoError(t, err)
pi = []store.PostInfo{}
err = json.Unmarshal(body, &pi)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1}}, pi)
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, "", comments.Comments[0].Text)
assert.True(t, comments.Comments[0].Deleted)
assert.NoError(t, err)
require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, "", comments.Comments[0].Text, "permanent block clear comment")
assert.True(t, comments.Comments[0].Deleted, "permanent block set deleted comment's status")
// unblock
code, body = block(-1, "")
require.Equal(t, 200, code)
err = json.Unmarshal(body, &j)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, false, j["block"])
// block with ttl
makeTwoComments()
code, _ = block(1, "50ms")
require.Equal(t, 200, code)
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time")
// get as regular user
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, "", comments.Comments[0].Text)
assert.True(t, comments.Comments[0].Deleted)
assert.NoError(t, err)
require.Equal(t, 4, len(comments.Comments), "should have 4 comments")
assert.Equal(t, "test test #1", comments.Comments[2].Text, "comment not removed and not cleared")
assert.False(t, comments.Comments[2].Deleted, "not deleted")
srv.Cache = &cache.Nop{} // TODO: with lru cache it won't be refreshed and invalidated for long time
srv.pubRest.cache = cache.NewScache(cache.NewNopCache()) // TODO: with lru cache it won't be refreshed and invalidated for long
// time
time.Sleep(50 * time.Millisecond)
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, "test test #1", comments.Comments[0].Text)
assert.False(t, comments.Comments[0].Deleted)
assert.NoError(t, err)
require.Equal(t, 4, len(comments.Comments), "should have 4 comments")
assert.Equal(t, "test test #1", comments.Comments[2].Text, "restored")
assert.False(t, comments.Comments[2].Deleted)
assert.False(t, srv.adminRest.dataService.IsBlocked("remark42", "user1"))
assert.False(t, srv.adminRest.dataService.IsBlocked("remark42", "user2"))
}
func TestAdmin_BlockedList(t *testing.T) {
ts, _, teardown := startupT(t)
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2 name", ID: "user2"}}
// write comments for user1 and user2
_, err := srv.DataService.Create(c1)
assert.NoError(t, err)
_, err = srv.DataService.Create(c2)
assert.NoError(t, err)
// block user1
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d", ts.URL, "user1", 1), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), nil)
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=radio-t&block=%d&ttl=50ms", ts.URL, "user2", 1), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), nil)
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=radio-t", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
require.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
users := []store.BlockedUser{}
err = json.NewDecoder(res.Body).Decode(&users)
assert.Nil(t, err)
assert.Equal(t, 2, len(users), "two users blocked")
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)
assert.Equal(t, "user2", users[1].ID)
assert.Equal(t, "user2 name", users[1].Name)
t.Logf("%+v", users)
time.Sleep(150 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=radio-t", nil)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
require.NoError(t, err)
res, err = sendReq(t, req, adminUmputunToken)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
users = []store.BlockedUser{}
err = json.NewDecoder(res.Body).Decode(&users)
assert.Nil(t, err)
assert.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, 1, len(users), "one user left blocked")
}
func TestAdmin_ReadOnly(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
_, err := srv.DataService.Create(c1)
assert.Nil(t, err)
assert.NoError(t, err)
_, err = srv.DataService.Create(c2)
assert.Nil(t, err)
assert.NoError(t, err)
info, err := srv.DataService.Info(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, 0)
assert.Nil(t, err)
info, err := srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.False(t, info.ReadOnly)
// set post to read-only
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
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: "radio-t", URL: "https://radio-t.com/blah"}, 0)
assert.Nil(t, err)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.True(t, info.ReadOnly)
// try to write comment
c := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
b, err := json.Marshal(c)
assert.Nil(t, err, "can't marshal comment %+v", c)
assert.NoError(t, err, "can't marshal comment %+v", c)
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
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=radio-t&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
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: "radio-t", URL: "https://radio-t.com/blah"}, 0)
assert.Nil(t, err)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.False(t, info.ReadOnly)
// try to write comment
c = store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
b, err = json.Marshal(c)
assert.Nil(t, err, "can't marshal comment %+v", c)
assert.NoError(t, err, "can't marshal comment %+v", c)
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
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)
}
@@ -443,20 +501,21 @@ 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=radio-t&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
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: "radio-t", URL: "https://radio-t.com/blah"}, 0)
assert.NotNil(t, err)
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.Error(t, err)
res, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&format=tree")
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&format=tree")
assert.Equal(t, 200, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
assert.True(t, comments.Info.ReadOnly)
t.Logf("%+v", comments)
@@ -466,36 +525,38 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"},
Timestamp: time.Date(2001, 1, 1, 1, 1, 1, 0, time.Local)}
_, err := srv.DataService.Create(c1)
assert.Nil(t, err)
assert.NoError(t, err)
info, err := srv.DataService.Info(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, 10)
assert.Nil(t, err)
info, err := srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 10)
assert.NoError(t, err)
assert.True(t, info.ReadOnly, "ro by age")
// set post to read-only
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
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: "radio-t", URL: "https://radio-t.com/blah"}, 0)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.True(t, info.ReadOnly)
// reset post's read-only
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
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: "radio-t", URL: "https://radio-t.com/blah"}, 0)
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
assert.NoError(t, err)
assert.True(t, info.ReadOnly)
@@ -504,53 +565,55 @@ func TestAdmin_Verify(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
_, err := srv.DataService.Create(c1)
assert.Nil(t, err)
assert.NoError(t, err)
_, err = srv.DataService.Create(c2)
assert.Nil(t, err)
assert.NoError(t, err)
verified := srv.DataService.IsVerified("radio-t", "user1")
verified := srv.DataService.IsVerified("remark42", "user1")
assert.False(t, verified)
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=radio-t&verified=1", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), nil)
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("radio-t", "user1")
verified = srv.DataService.IsVerified("remark42", "user1")
assert.True(t, verified)
res, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time")
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.NoError(t, err)
require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, "test test #1", comments.Comments[0].Text)
assert.True(t, comments.Comments[0].User.Verified)
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=radio-t&verified=0", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), nil)
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("radio-t", "user1")
verified = srv.DataService.IsVerified("remark42", "user1")
assert.False(t, verified)
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
assert.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.NoError(t, err)
require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, "test test #1", comments.Comments[0].Text)
assert.False(t, comments.Comments[0].User.Verified)
}
@@ -560,14 +623,14 @@ func TestAdmin_ExportStream(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
addComment(t, c1, ts)
addComment(t, c2, ts)
body, code := getWithAdminAuth(t, ts.URL+"/api/v1/admin/export?site=radio-t&mode=stream")
body, code := getWithAdminAuth(t, ts.URL+"/api/v1/admin/export?site=remark42&mode=stream")
assert.Equal(t, 200, code)
assert.Equal(t, 3, strings.Count(body, "\n"))
assert.Equal(t, 2, strings.Count(body, "\"text\""))
@@ -579,14 +642,14 @@ func TestAdmin_ExportFile(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
addComment(t, c1, ts)
addComment(t, c2, ts)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=radio-t&mode=file", nil)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", nil)
require.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
@@ -608,24 +671,32 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
_, err := srv.DataService.Create(c1)
assert.Nil(t, err)
assert.NoError(t, err)
_, err = srv.DataService.Create(c2)
assert.Nil(t, err)
assert.NoError(t, err)
comments, err := srv.DataService.User("radio-t", "user1", 0, 0)
assert.Nil(t, err)
comments, err := srv.DataService.User("remark42", "user1", 0, 0, store.User{})
assert.NoError(t, err)
assert.Equal(t, 1, len(comments), "a comment for user1")
email, err := srv.DataService.SetUserEmail("remark42", "user1", "test@example.org")
assert.NoError(t, err)
assert.Equal(t, "test@example.org", email, "new email for user1")
email, err = srv.DataService.GetUserEmail("remark42", "user1")
assert.NoError(t, err)
assert.Equal(t, "test@example.org", email, "new email for user1 is readable")
claims := token.Claims{
SessionOnly: true,
StandardClaims: jwt.StandardClaims{
Audience: "radio-t",
Audience: "remark42",
Id: "1234567",
Issuer: "remark42",
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
@@ -640,53 +711,60 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
},
}
require.NoError(t, os.MkdirAll("/tmp/ava-remark42/42", 0700))
require.NoError(t, ioutil.WriteFile("/tmp/ava-remark42/42/pic.image", []byte("some image data"), 0600))
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))
tkn, err := srv.Authenticator.TokenService().Token(claims)
assert.Nil(t, err)
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)
assert.Nil(t, err)
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("radio-t", "user1", 0, 0)
_, err = srv.DataService.User("remark42", "user1", 0, 0, store.User{})
assert.EqualError(t, err, "no comments for user user1 in store")
email, err = srv.DataService.GetUserEmail("remark42", "user1")
assert.NoError(t, err)
assert.Empty(t, email, "user1 email was deleted")
}
func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
_, err := srv.DataService.Create(c1)
assert.Nil(t, err)
assert.NoError(t, err)
_, err = srv.DataService.Create(c2)
assert.Nil(t, err)
assert.NoError(t, err)
// 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)
assert.Nil(t, err)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode)
// try with bad auth
claims := token.Claims{
SessionOnly: true,
StandardClaims: jwt.StandardClaims{
Audience: "radio-t",
Audience: "remark42",
Id: "1234567",
Issuer: "remark42",
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
@@ -701,39 +779,42 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
}
tkn, err := srv.Authenticator.TokenService().Token(claims)
assert.Nil(t, err)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
assert.Nil(t, err)
assert.NoError(t, err)
req.SetBasicAuth("admin", "bad-password")
resp, err = client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 403, resp.StatusCode)
// try bad user
badClaims := claims
badClaims.User.ID = "no-such-id"
tkn, err = srv.Authenticator.TokenService().Token(badClaims)
assert.Nil(t, err)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
assert.Nil(t, err)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, 400, resp.StatusCode, resp.Status)
// try without deleteme flag
badClaims2 := claims
badClaims2.User.SetBoolAttr("delete_me", false)
tkn, err = srv.Authenticator.TokenService().Token(badClaims2)
assert.Nil(t, err)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
assert.Nil(t, err)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 403, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.True(t, strings.Contains(string(b), "can't use provided token"))
}
@@ -741,27 +822,28 @@ func TestAdmin_GetUserInfo(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
_, err := srv.DataService.Create(c1)
assert.Nil(t, err)
assert.NoError(t, err)
_, err = srv.DataService.Create(c2)
assert.Nil(t, err)
assert.NoError(t, err)
body, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=radio-t&url=https://radio-t.com/blah", ts.URL))
body, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=remark42&url=https://radio-t.com/blah",
ts.URL))
assert.Equal(t, 200, code)
u := store.User{}
err = json.Unmarshal([]byte(body), &u)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, store.User{Name: "user1 name", ID: "user1", Picture: "", IP: "823688dafca7393d24c871a2da98a84d8732e927",
Admin: false, Blocked: false, Verified: false}, u)
_, code = get(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=radio-t&url=https://radio-t.com/blah", ts.URL))
_, code = get(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=remark42&url=https://radio-t.com/blah", ts.URL))
assert.Equal(t, 401, code, "no auth")
_, code = getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/userX?site=radio-t&url=https://radio-t.com/blah", ts.URL))
_, code = getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/userX?site=remark42&url=https://radio-t.com/blah", ts.URL))
assert.Equal(t, 400, code, "no info about user")
}
+67 -8
View File
@@ -12,22 +12,23 @@ import (
"time"
"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/go-pkgz/rest/cache"
"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
type Migrator struct {
Cache cache.LoadingCache
Cache LoadingCache
NativeImporter migrator.Importer
DisqusImporter migrator.Importer
WordPressImporter migrator.Importer
NativeExporter migrator.Exporter
URLMapperMaker migrator.MapperMaker
KeyStore KeyStore
busy map[string]bool
@@ -36,7 +37,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
@@ -98,7 +99,9 @@ func (m *Migrator) importFormCtrl(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, R.JSON{"status": "import request accepted"})
}
func (m *Migrator) importWaitCtrl(w http.ResponseWriter, r *http.Request) {
// GET /wait?site=site-id
// waits for migration operation (import or remap)
func (m *Migrator) waitCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
timeOut := time.Minute * 15
if v := r.URL.Query().Get("timeout"); v != "" {
@@ -152,8 +155,64 @@ func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// POST /remap?site=site-id
// remap urls in comments based on given rules (oldUrl newUrl)
func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
// create new url-mapper from given rules in body
mapper, err := m.URLMapperMaker(r.Body)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "remap failed, bad given rules", rest.ErrDecode)
return
}
defer r.Body.Close()
// start remap procedure with mapper
go func() {
m.setBusy(siteID, true)
defer m.setBusy(siteID, false)
// do export
fh, e := ioutil.TempFile("", "remark42_convert")
if e != nil {
log.Printf("[WARN] failed to make temp file %+v", e)
return
}
defer func() {
if e = os.Remove(fh.Name()); e != nil {
log.Printf("[WARN] failed to remove temp file %+v", e)
}
}()
log.Printf("[DEBUG] start export for site=%s", siteID)
if _, e = m.NativeExporter.Export(fh, siteID); e != nil {
log.Printf("[WARN] export failed with %+v", e)
return
}
if _, e = fh.Seek(0, 0); e != nil {
log.Printf("[WARN] failed to seek file %+v", e)
return
}
log.Printf("[DEBUG] start import for site=%s", siteID)
mappedReader := migrator.WithMapper(fh, mapper)
size, e := m.NativeImporter.Import(mappedReader, siteID)
if e != nil {
log.Printf("[WARN] import failed with %+v", e)
return
}
m.Cache.Flush(cache.Flusher(siteID).Scopes(siteID))
log.Printf("[DEBUG] convert request completed. site=%s, comments=%d", siteID, size)
}()
render.Status(r, http.StatusAccepted)
render.JSON(w, r, R.JSON{"status": "convert request accepted"})
}
// 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() {
@@ -174,7 +233,7 @@ func (m *Migrator) runImport(siteID string, provider string, tmpfile string) {
}
log.Printf("[DEBUG] import request for site=%s, provider=%s", siteID, provider)
fh, err := os.Open(tmpfile)
fh, err := os.Open(tmpfile) // nolint
if err != nil {
log.Printf("[WARN] import failed, %v", err)
return
+213 -64
View File
@@ -3,6 +3,7 @@ package api
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"io/ioutil"
@@ -15,36 +16,52 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/service"
)
func TestMigrator_Import(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah2"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>",
"user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com",
"admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},
"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one",
"id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
assert.Nil(t, err)
assert.NoError(t, err)
resp, err := client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "{\"status\":\"import request accepted\"}\n", string(b))
waitForImportCompletion(t, ts)
waitForMigrationCompletion(t, ts)
}
func TestMigrator_ImportForm(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah2"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>",
"user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com",
"admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},
"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one",
"id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
bodyBuf := &bytes.Buffer{}
bodyWriter := multipart.NewWriter(bodyBuf)
@@ -56,15 +73,15 @@ func TestMigrator_ImportForm(t *testing.T) {
require.NoError(t, bodyWriter.Close())
authts := strings.Replace(ts.URL, "http://", "http://admin:password@", 1)
resp, err := http.Post(authts+"/api/v1/admin/import/form?site=radio-t&provider=native", contentType, bodyBuf)
assert.Nil(t, err)
resp, err := http.Post(authts+"/api/v1/admin/import/form?site=remark42&provider=native", contentType, bodyBuf)
assert.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "{\"status\":\"import request accepted\"}\n", string(b))
waitForImportCompletion(t, ts)
waitForMigrationCompletion(t, ts)
}
func TestMigrator_ImportFromWP(t *testing.T) {
@@ -74,33 +91,40 @@ func TestMigrator_ImportFromWP(t *testing.T) {
r := strings.NewReader(strings.Replace(xmlTestWP, "'", "`", -1))
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=wordpress", r)
assert.Nil(t, err)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=wordpress", r)
assert.NoError(t, err)
req.Header.Add("Content-Type", "application/xml; charset=utf-8")
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "{\"status\":\"import request accepted\"}\n", string(b))
waitForImportCompletion(t, ts)
waitForMigrationCompletion(t, ts)
}
func TestMigrator_ImportRejected(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah2"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>",
"user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com",
"admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},
"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one",
"id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native&secret=XYZ", r)
assert.Nil(t, err)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native&secret=XYZ", r)
assert.NoError(t, err)
resp, err := client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
@@ -108,82 +132,104 @@ func TestMigrator_ImportDouble(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
tmpl := `{"id":"%d","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
tmpl := `{"id":"%d","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev",
"picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
recs := []string{}
for i := 0; i < 150; i++ {
for i := 0; i < 50; i++ {
recs = append(recs, fmt.Sprintf(tmpl, i))
}
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
assert.Nil(t, err)
assert.NoError(t, err)
resp, err := client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
client = &http.Client{Timeout: 1 * time.Second}
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
client = &http.Client{Timeout: 5 * time.Second}
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
assert.Nil(t, err)
assert.NoError(t, err)
resp, err = client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusConflict, resp.StatusCode)
waitForImportCompletion(t, ts)
waitForMigrationCompletion(t, ts)
}
func TestMigrator_ImportWaitExpired(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
tmpl := `{"id":"%d","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
recs := []string{}
for i := 0; i < 150; i++ {
tmpl := `{"id":"%d","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev",
"picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
nRecs := 50
recs := make([]string, 0, nRecs)
for i := 0; i < nRecs; i++ {
recs = append(recs, fmt.Sprintf(tmpl, i))
}
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with `nRecs` records
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
require.Nil(t, err)
require.NoError(t, err)
resp, err := client.Do(req)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
client = &http.Client{Timeout: 10 * time.Second}
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/import/wait?site=radio-t&timeout=100ms", nil)
client = &http.Client{Timeout: 5 * time.Second}
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/wait?site=remark42&timeout=5ms", nil)
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)
waitForImportCompletion(t, ts)
waitForMigrationCompletion(t, ts)
}
func TestMigrator_Export(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah2"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
r := strings.NewReader(`{"version":1} {"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>",
"user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com",
"admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},
"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one",
"id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
// import comments first
client := &http.Client{Timeout: 1 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
require.Nil(t, err)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err := client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusAccepted, resp.StatusCode)
waitForImportCompletion(t, ts)
waitForMigrationCompletion(t, ts)
// check file mode
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=radio-t", nil)
require.Nil(t, err)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", nil)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
require.Equal(t, "application/gzip", resp.Header.Get("Content-Type"))
@@ -191,44 +237,147 @@ func TestMigrator_Export(t *testing.T) {
assert.NoError(t, err)
ungzBody, err := ioutil.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=radio-t", nil)
require.Nil(t, err)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=stream&site=remark42", nil)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
resp, err = client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
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)
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=radio-t", nil)
require.Nil(t, err)
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42", nil)
require.NoError(t, err)
resp, err = client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func waitForImportCompletion(t *testing.T, ts *httptest.Server) {
func TestMigrator_Remap(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
// create 2 comments in https://remark42.com/demo/
c1 := store.Comment{Text: "first comment", Timestamp: time.Now(),
Locator: store.Locator{SiteID: "remark42", URL: "https://remark42.com/demo/"}, User: store.User{ID: "u1"}}
_, err := srv.DataService.Create(c1)
require.NoError(t, err)
c2 := store.Comment{Text: "second comment", Timestamp: time.Now(),
Locator: store.Locator{SiteID: "remark42", URL: "https://remark42.com/demo/"}, User: store.User{ID: "u2"}}
_, err = srv.DataService.Create(c2)
require.NoError(t, err)
// create 1 comment in https://remark42.com/demo-another/
c3 := store.Comment{Text: "third comment", Timestamp: time.Now(),
Locator: store.Locator{SiteID: "remark42", URL: "https://remark42.com/demo-another/"}, User: store.User{ID: "u3"}}
_, err = srv.DataService.Create(c3)
require.NoError(t, err)
// set url https://remark42.com/demo-another/ to be readonly
err = srv.DataService.SetMetas("remark42", []service.UserMetaData{}, []service.PostMetaData{{
URL: "https://remark42.com/demo-another/",
ReadOnly: true,
}})
require.NoError(t, err)
// check that comments created as expected
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo/")
require.Equal(t, 200, code)
comments := commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 2, comments.Info.Count)
require.False(t, comments.Info.ReadOnly)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
require.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.True(t, comments.Info.ReadOnly)
// we want remap urls to another domain - www.remark42.com
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)
// after remap finished we should find comments from new urls
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://www.remark42.com/demo/")
require.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 2, comments.Info.Count)
require.False(t, comments.Info.ReadOnly)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://www.remark42.com/demo-another/")
require.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 1, comments.Info.Count)
require.True(t, comments.Info.ReadOnly)
// should find nothing from previous url
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo/")
require.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 0, comments.Info.Count)
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
require.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
require.NoError(t, err)
require.Equal(t, 0, comments.Info.Count)
}
func TestMigrator_RemapReject(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
// without admin credentials
client := &http.Client{Timeout: 1 * time.Second}
rules := strings.NewReader(`https://remark42.com/* https://www.remark42.com/*`)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/remap?site=remark42", rules)
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/import/wait?site=radio-t", nil)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/wait?site=remark42", nil)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
assert.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, "{\"site_id\":\"radio-t\",\"status\":\"completed\"}\n", string(b))
assert.Equal(t, "{\"site_id\":\"remark42\",\"status\":\"completed\"}\n", string(b))
}
var xmlTestWP = `
@@ -320,7 +469,7 @@ var xmlTestWP = `
<category domain="post_tag" nicename="alts"><![CDATA[alts]]></category>
<category domain="post_tag" nicename="role-playing"><![CDATA[role playing]]></category>
<category domain="category" nicename="stuff"><![CDATA[Stuff]]></category>
<category domain="post_tag" nicename="wierd-in-a-cant-quite-help-myself-way"><![CDATA[wierd in a can't quite help myself way]]></category>
<category domain="post_tag" nicename="weird-in-a-cant-quite-help-myself-way"><![CDATA[weird in a can't quite help myself way]]></category>
<wp:postmeta>
<wp:meta_key><![CDATA[_edit_last]]></wp:meta_key>
<wp:meta_value><![CDATA[2]]></wp:meta_value>
+309 -118
View File
@@ -10,25 +10,27 @@ import (
"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"
"github.com/go-pkgz/lcw"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/go-pkgz/rest/cache"
"github.com/go-pkgz/rest/logger"
"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
@@ -37,13 +39,14 @@ type Rest struct {
DataService *service.DataStore
Authenticator *auth.Service
Cache cache.LoadingCache
Cache LoadingCache
ImageProxy *proxy.Image
CommentFormatter *store.CommentFormatter
Migrator *Migrator
NotifyService *notify.Service
ImageService *image.Service
AnonVote bool
WebRoot string
RemarkURL string
ReadOnlyAge int
@@ -52,14 +55,30 @@ type Rest struct {
Low int
Critical int
}
UpdateLimiter float64
UpdateLimiter float64
EmailNotifications bool
EmojiEnabled bool
SimpleView bool
ProxyCORS bool
SendJWTHeader bool
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
SSLConfig SSLConfig
httpsServer *http.Server
httpServer *http.Server
lock sync.Mutex
adminService admin
pubRest public
privRest private
adminRest admin
rssRest rss
}
// LoadingCache defines interface for caching
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
@@ -156,34 +175,36 @@ func (s *Rest) makeHTTPServer(port int, router http.Handler) *http.Server {
Addr: fmt.Sprintf(":%d", port),
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 120 * time.Second, // TODO: such a long timeout needed for blocking export (backup) request
IdleTimeout: 30 * time.Second,
// WriteTimeout: 120 * time.Second, // TODO: such a long timeout needed for blocking export (backup) request
IdleTimeout: 30 * time.Second,
}
}
func (s *Rest) routes() chi.Router {
router := chi.NewRouter()
router.Use(middleware.RealIP, R.Recoverer(log.Default()))
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second))
router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default()))
router.Use(R.AppInfo("remark42", "umputun", s.Version), R.Ping)
s.adminService = admin{
dataService: s.DataService,
migrator: s.Migrator,
cache: s.Cache,
authenticator: s.Authenticator,
readOnlyAge: s.ReadOnlyAge,
s.pubRest, s.privRest, s.adminRest, s.rssRest = s.controllerGroups() // assign controllers for groups
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)
}
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
@@ -191,12 +212,14 @@ func (s *Rest) routes() chi.Router {
authHandler, avatarHandler := s.Authenticator.Handlers()
router.Group(func(r chi.Router) {
r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(5, nil)), middleware.NoCache)
r.Use(middleware.Timeout(5 * time.Second))
r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)), middleware.NoCache)
r.Mount("/auth", authHandler)
})
router.Group(func(r chi.Router) {
r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)), middleware.NoCache)
r.Use(middleware.Timeout(5 * time.Second))
r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
r.Mount("/avatar", avatarHandler)
})
@@ -206,140 +229,160 @@ func (s *Rest) routes() chi.Router {
router.Route("/api/v1", func(rapi 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)
})
// open routes
rapi.Group(func(ropen chi.Router) {
ropen.Use(middleware.Timeout(30 * time.Second))
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody)
ropen.Get("/find", s.findCommentsCtrl)
ropen.Get("/id/{id}", s.commentByIDCtrl)
ropen.Get("/comments", s.findUserCommentsCtrl)
ropen.Get("/last/{limit}", s.lastCommentsCtrl)
ropen.Get("/count", s.countCtrl)
ropen.Post("/counts", s.countMultiCtrl)
ropen.Get("/list", s.listCtrl)
ropen.Get("/config", s.configCtrl)
ropen.Post("/preview", s.previewCommentCtrl)
ropen.Get("/info", s.infoCtrl)
ropen.Get("/find", s.pubRest.findCommentsCtrl)
ropen.Get("/id/{id}", s.pubRest.commentByIDCtrl)
ropen.Get("/comments", s.pubRest.findUserCommentsCtrl)
ropen.Get("/last/{limit}", s.pubRest.lastCommentsCtrl)
ropen.Get("/count", s.pubRest.countCtrl)
ropen.Post("/counts", s.pubRest.countMultiCtrl)
ropen.Get("/list", s.pubRest.listCtrl)
ropen.Post("/preview", s.pubRest.previewCommentCtrl)
ropen.Get("/info", s.pubRest.infoCtrl)
ropen.Get("/img", s.ImageProxy.Handler)
ropen.Route("/rss", func(rrss chi.Router) {
rrss.Get("/post", s.rssPostCommentsCtrl)
rrss.Get("/site", s.rssSiteCommentsCtrl)
rrss.Get("/reply", s.rssRepliesCtrl)
rrss.Get("/post", s.rssRest.postCommentsCtrl)
rrss.Get("/site", s.rssRest.siteCommentsCtrl)
rrss.Get("/reply", s.rssRest.repliesCtrl)
})
})
// open routes, cached
rapi.Group(func(ropen chi.Router) {
ropen.Use(middleware.Timeout(30 * time.Second))
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
ropen.Use(authMiddleware.Trace, logInfoWithBody)
ropen.Get("/picture/{user}/{id}", s.loadPictureCtrl)
ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl)
})
// protected routes, require auth
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(30 * time.Second))
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
rauth.Use(authMiddleware.Auth, middleware.NoCache, logInfoWithBody)
rauth.Get("/user", s.userInfoCtrl)
rauth.Get("/userdata", s.userAllDataCtrl)
rauth.Use(authMiddleware.Auth, matchSiteID, middleware.NoCache, logInfoWithBody)
rauth.Get("/user", s.privRest.userInfoCtrl)
rauth.Get("/userdata", s.privRest.userAllDataCtrl)
})
// admin routes, require auth and admin users only
rapi.Route("/admin", func(radmin chi.Router) {
radmin.Use(middleware.Timeout(30 * time.Second))
radmin.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly)
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
radmin.Use(middleware.NoCache, logInfoWithBody)
radmin.Delete("/comment/{id}", s.adminService.deleteCommentCtrl)
radmin.Put("/user/{userid}", s.adminService.setBlockCtrl)
radmin.Delete("/user/{userid}", s.adminService.deleteUserCtrl)
radmin.Get("/user/{userid}", s.adminService.getUserInfoCtrl)
radmin.Get("/deleteme", s.adminService.deleteMeRequestCtrl)
radmin.Put("/verify/{userid}", s.adminService.setVerifyCtrl)
radmin.Put("/pin/{id}", s.adminService.setPinCtrl)
radmin.Get("/blocked", s.adminService.blockedUsersCtrl)
radmin.Put("/readonly", s.adminService.setReadOnlyCtrl)
radmin.Put("/title/{id}", s.adminService.setTitleCtrl)
radmin.Delete("/comment/{id}", s.adminRest.deleteCommentCtrl)
radmin.Put("/user/{userid}", s.adminRest.setBlockCtrl)
radmin.Delete("/user/{userid}", s.adminRest.deleteUserCtrl)
radmin.Get("/user/{userid}", s.adminRest.getUserInfoCtrl)
radmin.Get("/deleteme", s.adminRest.deleteMeRequestCtrl)
radmin.Put("/verify/{userid}", s.adminRest.setVerifyCtrl)
radmin.Put("/pin/{id}", s.adminRest.setPinCtrl)
radmin.Get("/blocked", s.adminRest.blockedUsersCtrl)
radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl)
radmin.Put("/title/{id}", s.adminRest.setTitleCtrl)
// migrator
radmin.Get("/export", s.adminService.migrator.exportCtrl)
radmin.Post("/import", s.adminService.migrator.importCtrl)
radmin.Post("/import/form", s.adminService.migrator.importFormCtrl)
radmin.Get("/import/wait", s.adminService.migrator.importWaitCtrl)
radmin.Get("/export", s.adminRest.migrator.exportCtrl)
radmin.Post("/import", s.adminRest.migrator.importCtrl)
radmin.Post("/import/form", s.adminRest.migrator.importFormCtrl)
radmin.Post("/remap", s.adminRest.migrator.remapCtrl)
radmin.Get("/wait", s.adminRest.migrator.waitCtrl)
})
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(10 * time.Second))
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
rauth.Use(authMiddleware.Auth)
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)
rauth.Use(middleware.NoCache, logInfoWithBody)
rauth.Put("/comment/{id}", s.updateCommentCtrl)
rauth.Post("/comment", s.createCommentCtrl)
rauth.With(rejectAnonUser).Put("/vote/{id}", s.voteCtrl)
rauth.With(rejectAnonUser).Post("/deleteme", s.deleteMeCtrl)
rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl)
rauth.Post("/comment", s.privRest.createCommentCtrl)
rauth.Put("/vote/{id}", s.privRest.voteCtrl)
rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl)
rauth.With(rejectAnonUser).Get("/email", s.privRest.getEmailCtrl)
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
})
// protected routes, anonymous rejected
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, rejectAnonUser)
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
rauth.Post("/picture", s.savePictureCtrl)
rauth.Post("/picture", s.privRest.savePictureCtrl)
})
})
// open routes on root level
router.Group(func(rroot chi.Router) {
tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil))
rroot.Get("/index.html", s.getStartedCtrl)
rroot.Get("/robots.txt", s.getRobotsCtrl)
rroot.Use(middleware.Timeout(10 * time.Second))
rroot.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil)))
rroot.Get("/index.html", s.pubRest.getStartedCtrl)
rroot.Get("/robots.txt", s.pubRest.robotsCtrl)
rroot.Get("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
rroot.Post("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
})
// file server for static content from /web
addFileServer(router, "/web", http.Dir(s.WebRoot))
addFileServer(router, "/web", http.Dir(s.WebRoot), s.Version)
return router
}
func (s *Rest) alterComments(comments []store.Comment, r *http.Request) (res []store.Comment) {
func (s *Rest) controllerGroups() (public, private, admin, rss) {
res = s.adminService.alterComments(comments, r) // apply admin's alteration
// prepare vote info for client view
vote := func(c store.Comment, r *http.Request) store.Comment {
c.Vote = 0 // default is "none" (not voted)
user, err := rest.GetUserInfo(r)
if err != nil {
c.Votes = nil // hide voters list and don't set Vote for non-authed user
return c
}
if v, ok := c.Votes[user.ID]; ok {
if v {
c.Vote = 1
} else {
c.Vote = -1
}
}
c.Votes = nil // hide voters list
return c
pubGrp := public{
dataService: s.DataService,
cache: s.Cache,
imageService: s.ImageService,
commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge,
webRoot: s.WebRoot,
}
for i, c := range res {
c = vote(c, r)
res[i] = c
privGrp := private{
dataService: s.DataService,
cache: s.Cache,
imageService: s.ImageService,
commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge,
authenticator: s.Authenticator,
notifyService: s.NotifyService,
remarkURL: s.RemarkURL,
anonVote: s.AnonVote,
templates: templates.NewFS(),
}
return res
admGrp := admin{
dataService: s.DataService,
migrator: s.Migrator,
cache: s.Cache,
authenticator: s.Authenticator,
readOnlyAge: s.ReadOnlyAge,
}
rssGrp := rss{
dataService: s.DataService,
cache: s.Cache,
}
return pubGrp, privGrp, admGrp, rssGrp
}
// updateLimiter returns UpdateLimiter if set, or 10 if not
@@ -351,8 +394,62 @@ func (s *Rest) updateLimiter() float64 {
return lmt
}
// GET /config?site=siteID - returns configuration
func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
admins, _ := s.DataService.AdminStore.Admins(siteID)
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"`
SendJWTHeader bool `json:"send_jwt_header"`
}{
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,
SendJWTHeader: s.SendJWTHeader,
}
cnf.Auth = []string{}
for _, ap := range s.Authenticator.Providers() {
cnf.Auth = append(cnf.Auth, ap.Name())
}
if cnf.Admins == nil { // prevent json serialization to nil
cnf.Admins = []string{}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, cnf)
}
// serves static files from /web or embedded by statik
func addFileServer(r chi.Router, path string, root http.FileSystem) {
func addFileServer(r chi.Router, path string, root http.FileSystem, version string) {
var webFS http.Handler
@@ -369,20 +466,22 @@ func addFileServer(r chi.Router, path string, root http.FileSystem) {
origPath := path
webFS = http.StripPrefix(path, webFS)
if path != "/" && path[len(path)-1] != '/' {
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
r.Get(path, http.RedirectHandler(path+"/", http.StatusMovedPermanently).ServeHTTP)
path += "/"
}
path += "*"
r.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(20, nil))).
Get(path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// don't show dirs, just serve files
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != (origPath+"/") {
http.NotFound(w, r)
return
}
webFS.ServeHTTP(w, r)
}))
r.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(20, nil)),
middleware.Timeout(10*time.Second),
cacheControl(time.Hour, version),
).Get(path, func(w http.ResponseWriter, r *http.Request) {
// don't show dirs, just serve files
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != (origPath+"/") {
http.NotFound(w, r)
return
}
webFS.ServeHTTP(w, r)
})
}
func encodeJSONWithHTML(v interface{}) ([]byte, error) {
@@ -448,3 +547,95 @@ func rejectAnonUser(next http.Handler) http.Handler {
}
return http.HandlerFunc(fn)
}
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
func matchSiteID(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// skip for basic auth user
if user.Name == "admin" && user.ID == "admin" {
next.ServeHTTP(w, r)
return
}
siteID := r.URL.Query().Get("site")
if siteID != "" && user.SiteID != siteID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// cacheControl is a middleware setting cache expiration. Using url+version as etag
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
etag := func(r *http.Request, version string) string {
s := version + ":" + r.URL.String()
return store.EncodeID(s)
}
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
e := `"` + etag(r, version) + `"`
w.Header().Set("Etag", e)
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, e) {
w.WriteHeader(http.StatusNotModified)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// 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)
}
}
func parseError(err error, defaultCode int) (code int) {
code = defaultCode
switch {
// voting errors
case strings.Contains(err.Error(), "can not vote for his own comment"):
code = rest.ErrVoteSelf
case strings.Contains(err.Error(), "already voted for"):
code = rest.ErrVoteDbl
case strings.Contains(err.Error(), "maximum number of votes exceeded for comment"):
code = rest.ErrVoteMax
case strings.Contains(err.Error(), "minimal score reached for comment"):
code = rest.ErrVoteMinScore
// edit errors
case strings.HasPrefix(err.Error(), "too late to edit"):
code = rest.ErrCommentEditExpired
case strings.HasPrefix(err.Error(), "parent comment with reply can't be edited"):
code = rest.ErrCommentEditChanged
}
return code
}
+329 -65
View File
@@ -1,30 +1,67 @@
package api
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"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/go-pkgz/rest/cache"
"github.com/hashicorp/go-multierror"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark/backend/app/store"
"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 {
dataService privStore
cache LoadingCache
readOnlyAge int
commentFormatter *store.CommentFormatter
imageService *image.Service
notifyService *notify.Service
authenticator *auth.Service
remarkURL string
anonVote bool
templates templates.FileReader
}
type privStore interface {
Create(comment store.Comment) (commentID string, err error)
EditComment(locator store.Locator, commentID string, req service.EditRequest) (comment store.Comment, err error)
Vote(req service.VoteReq) (comment store.Comment, err error)
Get(locator store.Locator, commentID string, user store.User) (store.Comment, error)
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)
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
ValidateComment(c *store.Comment) error
IsVerified(siteID string, userID string) bool
IsReadOnly(locator store.Locator) bool
IsBlocked(siteID string, userID string) bool
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
}
// POST /comment - adds comment, resets all immutable fields
func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
comment := store.Comment{}
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
@@ -33,20 +70,26 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
}
user := rest.MustGetUserInfo(r)
if user.ID != "admin" && user.SiteID != comment.Locator.SiteID {
rest.SendErrorJSON(w, r, http.StatusForbidden,
fmt.Errorf("site mismatch, %q not allowed to post to %s", user.SiteID, comment.Locator.SiteID), "invalid site",
rest.ErrCommentValidation)
return
}
comment.PrepareUntrusted() // clean all fields user not supposed to set
comment.User = user
comment.User.IP = strings.Split(r.RemoteAddr, ":")[0]
comment.Orig = comment.Text // original comment text, prior to md render
if err := s.DataService.ValidateComment(&comment); err != nil {
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 = s.commentFormatter.Format(comment)
// check if user blocked
if s.adminService.checkBlocked(comment.Locator.SiteID, comment.User) {
if s.dataService.IsBlocked(comment.Locator.SiteID, comment.User.ID) {
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "user blocked", rest.ErrUserBlocked)
return
}
@@ -56,7 +99,7 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
return
}
id, err := s.DataService.Create(comment)
id, err := s.dataService.Create(comment)
if err == service.ErrRestrictedWordsFound {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
return
@@ -66,17 +109,17 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
return
}
// DataService modifies comment
finalComment, err := s.DataService.Get(comment.Locator, id)
// dataService modifies comment
finalComment, err := s.dataService.Get(comment.Locator, id, rest.GetUserOrEmpty(r))
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't load created comment", rest.ErrInternal)
return
}
s.Cache.Flush(cache.Flusher(comment.Locator.SiteID).
s.cache.Flush(cache.Flusher(comment.Locator.SiteID).
Scopes(comment.Locator.URL, lastCommentsScope, comment.User.ID, comment.Locator.SiteID))
if s.NotifyService != nil {
s.NotifyService.Submit(finalComment)
if s.notifyService != nil {
s.notifyService.Submit(notify.Request{Comment: finalComment})
}
log.Printf("[DEBUG] created commend %+v", finalComment)
@@ -86,7 +129,7 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
}
// PUT /comment/{id}?site=siteID&url=post-url - update comment
func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
edit := struct {
Text string
@@ -107,7 +150,7 @@ func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
var currComment store.Comment
var err error
if currComment, err = s.DataService.Get(locator, id); err != nil {
if currComment, err = s.dataService.Get(locator, id, rest.GetUserOrEmpty(r)); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't find comment", rest.ErrCommentNotFound)
return
}
@@ -119,41 +162,53 @@ func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
}
editReq := service.EditRequest{
Text: s.CommentFormatter.FormatText(edit.Text),
Text: s.commentFormatter.FormatText(edit.Text),
Orig: edit.Text,
Summary: edit.Summary,
Delete: edit.Delete,
}
res, err := s.DataService.EditComment(locator, id, editReq)
res, err := s.dataService.EditComment(locator, id, editReq)
if err == service.ErrRestrictedWordsFound {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
return
}
if err != nil {
code := s.parseError(err, rest.ErrCommentRejected)
code := parseError(err, rest.ErrCommentRejected)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't update comment", code)
return
}
s.Cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID))
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID))
render.JSON(w, r, res)
}
// GET /user?site=siteID - returns user info
func (s *Rest) userInfoCtrl(w http.ResponseWriter, r *http.Request) {
func (s *private) userInfoCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
if siteID := r.URL.Query().Get("site"); siteID != "" {
user.Verified = s.DataService.IsVerified(siteID, user.ID)
user.Verified = s.dataService.IsVerified(siteID, user.ID)
email, err := s.dataService.GetUserEmail(siteID, user.ID)
if err != nil {
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
}
if len(email) > 0 {
user.EmailSubscription = true
}
}
render.JSON(w, r, user)
}
// PUT /vote/{id}?site=siteID&url=post-url&vote=1 - vote for/against comment
func (s *Rest) voteCtrl(w http.ResponseWriter, r *http.Request) {
func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
if !s.anonVote && strings.HasPrefix(user.ID, "anonymous_") {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
id := chi.URLParam(r, "id")
log.Printf("[DEBUG] vote for comment %s", id)
@@ -166,23 +221,257 @@ func (s *Rest) voteCtrl(w http.ResponseWriter, r *http.Request) {
}
// check if user blocked
if s.adminService.checkBlocked(locator.SiteID, user) {
if s.dataService.IsBlocked(locator.SiteID, user.ID) {
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "user blocked", rest.ErrUserBlocked)
return
}
comment, err := s.DataService.Vote(locator, id, user.ID, vote)
req := service.VoteReq{
Locator: locator,
CommentID: id,
UserID: user.ID,
UserIP: strings.Split(r.RemoteAddr, ":")[0],
Val: vote,
}
comment, err := s.dataService.Vote(req)
if err != nil {
code := s.parseError(err, rest.ErrVoteRejected)
code := parseError(err, rest.ErrVoteRejected)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't vote for comment", code)
return
}
s.Cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID))
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID))
render.JSON(w, r, R.JSON{"id": comment.ID, "score": comment.Score})
}
// getEmailCtrl gets email address for authenticated user.
// GET /email?site=siteID
func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
siteID := r.URL.Query().Get("site")
address, err := s.dataService.GetUserEmail(siteID, user.ID)
if err != nil {
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
}
render.JSON(w, r, R.JSON{"user": user, "address": address})
}
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
// GET /email/subscribe?site=siteID&address=someone@example.com
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)
return
}
existingAddress, err := s.dataService.GetUserEmail(siteID, user.ID)
if err != nil {
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)
return
}
claims := token.Claims{
Handshake: &token.Handshake{ID: user.ID + "::" + address},
StandardClaims: jwt.StandardClaims{
Audience: r.URL.Query().Get("site"),
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
Issuer: "remark42",
},
}
tkn, err := s.authenticator.TokenService().Token(claims)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to make verification token", rest.ErrInternal)
return
}
s.notifyService.SubmitVerification(
notify.VerificationRequest{
SiteID: siteID,
User: user.Name,
Email: address,
Token: tkn,
},
)
render.JSON(w, r, R.JSON{"user": user, "address": address})
}
// 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) {
tkn := r.URL.Query().Get("tkn")
if tkn == "" {
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal)
return
}
user := rest.MustGetUserInfo(r)
siteID := r.URL.Query().Get("site")
confClaims, err := s.authenticator.TokenService().Parse(tkn)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
return
}
if s.authenticator.TokenService().IsExpired(confClaims) {
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("expired"), "failed to verify confirmation token", rest.ErrInternal)
return
}
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)
return
}
address := elems[1]
log.Printf("[DEBUG] set email for user %s", user.ID)
val, err := s.dataService.SetUserEmail(siteID, user.ID, address)
if err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code)
return
}
// update User.Email from the token
claims, _, err := s.authenticator.TokenService().Get(r)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
return
}
claims.User.Email = address
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
return
}
render.JSON(w, r, R.JSON{"updated": true, "address": val})
}
// POST/GET /email/unsubscribe.html?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications
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, 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, 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, s.templates)
return
}
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, s.templates)
return
}
userID := elems[0]
address := elems[1]
existingAddress, err := s.dataService.GetUserEmail(siteID, userID)
if err != nil {
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, 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, s.templates)
return
}
log.Printf("[DEBUG] unsubscribe user %s", userID)
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, s.templates)
return
}
// clean User.Email from the token, if user has the token
claims, _, err := s.authenticator.TokenService().Get(r)
if err != nil {
log.Printf("[DEBUG] unsubscribed user doesn't have valid JWT token to update %s, %v", userID, err)
}
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, s.templates)
return
}
}
// MustExecute behaves like template.Execute, but panics if an error occurs.
MustExecute := func(tmpl *template.Template, wr io.Writer, data interface{}) {
if err := tmpl.Execute(wr, data); err != nil {
panic(err)
}
}
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())
}
// DELETE /email?site=siteID - removes user's email
func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] remove email for user %s", user.ID)
if err := s.dataService.DeleteUserDetail(siteID, user.ID, engine.UserEmail); err != nil {
code := parseError(err, rest.ErrInternal)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
return
}
// clean User.Email from the token
claims, _, err := s.authenticator.TokenService().Get(r)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
return
}
if claims.User.Email != "" {
claims.User.Email = ""
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
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 *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
user := rest.MustGetUserInfo(r)
userB, err := json.Marshal(&user)
@@ -213,7 +502,7 @@ func (s *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
// get comments in 100 in each paginated request
for i := 0; i < 100; i++ {
comments, errUser := s.DataService.User(siteID, user.ID, 100, i*100)
comments, errUser := s.dataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r))
if errUser != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal)
return
@@ -240,7 +529,7 @@ func (s *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
// POST /deleteme?site_id=site - requesting delete of all user info
// makes jwt with user info and sends it back as a part of json response
func (s *Rest) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
siteID := r.URL.Query().Get("site")
@@ -260,18 +549,18 @@ func (s *Rest) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
},
}
tokenStr, err := s.Authenticator.TokenService().Token(claims)
tokenStr, err := s.authenticator.TokenService().Token(claims)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't make token", rest.ErrInternal)
return
}
link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.RemarkURL, tokenStr)
link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.remarkURL, tokenStr)
render.JSON(w, r, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link})
}
// POST /image - save image with form request
func (s *Rest) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { // 5M max memory, if bigger will make a file
@@ -279,14 +568,14 @@ func (s *Rest) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
return
}
file, header, err := r.FormFile("file")
file, _, err := r.FormFile("file")
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get image file from the request", rest.ErrInternal)
return
}
defer func() { _ = file.Close() }()
id, err := s.ImageService.Save(header.Filename, user.ID, file)
id, err := s.imageService.Save(user.ID, file)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't save image", rest.ErrInternal)
return
@@ -295,37 +584,12 @@ func (s *Rest) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, R.JSON{"id": id})
}
func (s *Rest) isReadOnly(locator store.Locator) bool {
if s.ReadOnlyAge > 0 {
func (s *private) isReadOnly(locator store.Locator) bool {
if s.readOnlyAge > 0 {
// check RO by age
if info, e := s.DataService.Info(locator, s.ReadOnlyAge); e == nil && info.ReadOnly {
if info, e := s.dataService.Info(locator, s.readOnlyAge); e == nil && info.ReadOnly {
return true
}
}
return s.DataService.IsReadOnly(locator) // ro manually
}
func (s *Rest) parseError(err error, defaultCode int) (code int) {
code = defaultCode
switch {
// voting errors
case strings.Contains(err.Error(), "can not vote for his own comment"):
code = rest.ErrVoteSelf
case strings.Contains(err.Error(), "already voted for"):
code = rest.ErrVoteDbl
case strings.Contains(err.Error(), "maximum number of votes exceeded for comment"):
code = rest.ErrVoteMax
case strings.Contains(err.Error(), "minimal score reached for comment"):
code = rest.ErrVoteMinScore
// edit errors
case strings.HasPrefix(err.Error(), "too late to edit"):
code = rest.ErrCommentEditExpired
case strings.HasPrefix(err.Error(), "parent comment with reply can't be edited"):
code = rest.ErrCommentEditChanged
}
return code
return s.dataService.IsReadOnly(locator) // ro manually
}
File diff suppressed because it is too large Load Diff
+127 -117
View File
@@ -1,6 +1,7 @@
package api
import (
"bytes"
"crypto/sha1" // nolint
"encoding/base64"
"io"
@@ -9,49 +10,89 @@ import (
"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/go-pkgz/rest/cache"
"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/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"
)
// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy ]
type public struct {
dataService pubStore
cache LoadingCache
readOnlyAge int
commentFormatter *store.CommentFormatter
imageService *image.Service
webRoot string
}
type pubStore interface {
Create(comment store.Comment) (commentID string, err error)
Get(locator store.Locator, commentID string, user store.User) (store.Comment, error)
FindSince(locator store.Locator, sort string, user store.User, since time.Time) ([]store.Comment, error)
Last(siteID string, limit int, since time.Time, user store.User) ([]store.Comment, error)
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
UserCount(siteID, userID string) (int, error)
Count(locator store.Locator) (int, error)
List(siteID string, limit int, skip int) ([]store.PostInfo, error)
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
ValidateComment(c *store.Comment) error
IsReadOnly(locator store.Locator) bool
Counts(siteID string, postIDs []string) ([]store.PostInfo, error)
}
// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy]&view=[user|all]&since=unix_ts_msec
// find comments for given post. Returns in tree or plain formats, sorted
func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
sort := r.URL.Query().Get("sort")
if strings.HasPrefix(sort, " ") { // restore + replaced by " "
sort = "+" + sort[1:]
}
log.Printf("[DEBUG] get comments for %+v, sort %s, format %s", locator, sort, r.URL.Query().Get("format"))
view := r.URL.Query().Get("view")
since, err := s.parseSince(r)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse since", rest.ErrCommentNotFound)
return
}
format := r.URL.Query().Get("format")
if format == "tree" {
since = time.Time{} // since doesn't make sense for tree
}
log.Printf("[DEBUG] get comments for %+v, sort %s, format %s, since %v", locator, sort, format, since)
key := cache.NewKey(locator.SiteID).ID(URLKeyWithUser(r)).Scopes(locator.SiteID, locator.URL)
data, err := s.Cache.Get(key, func() ([]byte, error) {
comments, e := s.DataService.Find(locator, sort)
data, err := s.cache.Get(key, func() ([]byte, error) {
comments, e := s.dataService.FindSince(locator, sort, rest.GetUserOrEmpty(r), since)
if e != nil {
comments = []store.Comment{} // error should clear comments and continue for post info
}
maskedComments := s.alterComments(comments, r)
comments = s.applyView(comments, view)
var b []byte
switch r.URL.Query().Get("format") {
switch format {
case "tree":
tree := service.MakeTree(maskedComments, sort, s.ReadOnlyAge)
tree := service.MakeTree(comments, sort, s.readOnlyAge)
if tree.Nodes == nil { // eliminate json nil serialization
tree.Nodes = []*service.Node{}
}
if s.DataService.IsReadOnly(locator) {
if s.dataService.IsReadOnly(locator) {
tree.Info.ReadOnly = true
}
b, e = encodeJSONWithHTML(tree)
default:
withInfo := commentsWithInfo{Comments: maskedComments}
if info, ee := s.DataService.Info(locator, s.ReadOnlyAge); ee == nil {
withInfo := commentsWithInfo{Comments: comments}
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
withInfo.Info = info
}
b, e = encodeJSONWithHTML(withInfo)
@@ -70,7 +111,7 @@ func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
}
// POST /preview, body is a comment, returns rendered html
func (s *Rest) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
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)
@@ -84,23 +125,23 @@ func (s *Rest) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
}
comment.User = user
comment.Orig = comment.Text
if err = s.DataService.ValidateComment(&comment); err != nil {
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 = 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 *Rest) infoCtrl(w http.ResponseWriter, r *http.Request) {
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")}
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)
data, err := s.cache.Get(key, func() ([]byte, error) {
info, e := s.dataService.Info(locator, s.readOnlyAge)
if e != nil {
return nil, e
}
@@ -117,8 +158,9 @@ func (s *Rest) infoCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// GET /last/{limit}?site=siteID - last comments for the siteID, across all posts, sorted by time
func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
// 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) {
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] get last comments for %s", siteID)
@@ -127,13 +169,18 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
limit = 0
}
sinceTime, err := s.parseSince(r)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't translate since parameter", rest.ErrDecode)
return
}
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(lastCommentsScope)
data, err := s.Cache.Get(key, func() ([]byte, error) {
comments, e := s.DataService.Last(siteID, limit)
data, err := s.cache.Get(key, func() ([]byte, error) {
comments, e := s.dataService.Last(siteID, limit, sinceTime, rest.GetUserOrEmpty(r))
if e != nil {
return nil, e
}
comments = s.alterComments(comments, r)
// filter deleted from last comments view. Blocked marked as deleted and will sneak in without
filterDeleted := filterComments(comments, func(c store.Comment) bool { return !c.Deleted })
return encodeJSONWithHTML(filterDeleted)
@@ -150,7 +197,7 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
}
// GET /id/{id}?site=siteID&url=post-url - gets a comment by id
func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
siteID := r.URL.Query().Get("site")
@@ -158,12 +205,11 @@ func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
log.Printf("[DEBUG] get comments by id %s, %s %s", id, siteID, url)
comment, err := s.DataService.Get(store.Locator{SiteID: siteID, URL: url}, id)
comment, err := s.dataService.Get(store.Locator{SiteID: siteID, URL: url}, id, rest.GetUserOrEmpty(r))
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by id", rest.ErrCommentNotFound)
return
}
comment = s.alterComments([]store.Comment{comment}, r)[0]
render.Status(r, http.StatusOK)
if err = R.RenderJSONWithHTML(w, r, comment); err != nil {
@@ -172,7 +218,7 @@ func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
}
// GET /comments?site=siteID&user=id - returns comments for given userID
func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) {
func (s *public) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user")
siteID := r.URL.Query().Get("site")
@@ -190,14 +236,13 @@ func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) {
log.Printf("[DEBUG] get comments for userID %s, %s", userID, siteID)
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)
data, err := s.cache.Get(key, func() ([]byte, error) {
comments, e := s.dataService.User(siteID, userID, limit, 0, rest.GetUserOrEmpty(r))
if e != nil {
return nil, e
}
comments = s.alterComments(comments, r)
comments = filterComments(comments, func(c store.Comment) bool { return !c.Deleted })
count, e := s.DataService.UserCount(siteID, userID)
count, e := s.dataService.UserCount(siteID, userID)
if e != nil {
return nil, e
}
@@ -215,53 +260,10 @@ func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// GET /config?site=siteID - returns configuration
func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
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"`
}
cnf := config{
Version: s.Version,
EditDuration: int(s.DataService.EditDuration.Seconds()),
MaxCommentSize: s.DataService.MaxCommentSize,
Admins: s.DataService.AdminStore.Admins(siteID),
AdminEmail: s.DataService.AdminStore.Email(siteID),
LowScore: s.ScoreThresholds.Low,
CriticalScore: s.ScoreThresholds.Critical,
PositiveScore: s.DataService.PositiveScore,
ReadOnlyAge: s.ReadOnlyAge,
MaxImageSize: s.ImageService.Store.SizeLimit(),
}
cnf.Auth = []string{}
for _, ap := range s.Authenticator.Providers() {
cnf.Auth = append(cnf.Auth, ap.Name())
}
if cnf.Admins == nil { // prevent json serialization to nil
cnf.Admins = []string{}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, cnf)
}
// GET /count?site=siteID&url=post-url - get number of comments for given post
func (s *Rest) countCtrl(w http.ResponseWriter, r *http.Request) {
func (s *public) countCtrl(w http.ResponseWriter, r *http.Request) {
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
count, err := s.DataService.Count(locator)
count, err := s.dataService.Count(locator)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get count", rest.ErrPostNotFound)
return
@@ -270,10 +272,11 @@ func (s *Rest) countCtrl(w http.ResponseWriter, r *http.Request) {
}
// POST /counts?site=siteID - get number of comments for posts from post body
func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) {
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
}
@@ -284,8 +287,8 @@ func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) {
sha := base64.URLEncoding.EncodeToString(h[:])
key := cache.NewKey(siteID).ID(sha).Scopes(siteID)
data, err := s.Cache.Get(key, func() ([]byte, error) {
counts, e := s.DataService.Counts(siteID, posts)
data, err := s.cache.Get(key, func() ([]byte, error) {
counts, e := s.dataService.Counts(siteID, posts)
if e != nil {
return nil, e
}
@@ -303,7 +306,7 @@ func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) {
}
// GET /list?site=siteID&limit=50&skip=10 - list posts with comments
func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) {
func (s *public) listCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
limit, skip := 0, 0
@@ -316,8 +319,8 @@ func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) {
}
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID)
data, err := s.Cache.Get(key, func() ([]byte, error) {
posts, e := s.DataService.List(siteID, limit, skip)
data, err := s.cache.Get(key, func() ([]byte, error) {
posts, e := s.dataService.List(siteID, limit, skip)
if e != nil {
return nil, e
}
@@ -330,28 +333,14 @@ func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) {
}
if err = R.RenderJSONFromBytes(w, r, data); err != nil {
log.Printf("[WARN] can't render posts lits for site %s", siteID)
log.Printf("[WARN] can't render posts list for site %s", siteID)
}
}
// GET /picture/{user}/{id} - get picture
func (s *Rest) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
imgContentType := func(img string) string {
img = strings.ToLower(img)
switch {
case strings.HasSuffix(img, ".png"):
return "image/png"
case strings.HasSuffix(img, ".jpg") || strings.HasSuffix(img, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(img, ".gif"):
return "image/gif"
}
return "image/*"
}
func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "user") + "/" + chi.URLParam(r, "id")
imgRdr, size, err := s.ImageService.Load(id)
img, err := s.imageService.Load(id)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get image "+id, rest.ErrAssetNotFound)
return
@@ -367,23 +356,17 @@ func (s *Rest) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
}
}
defer func() {
if e := imgRdr.Close(); e != nil {
log.Printf("[WARN] failed to close reader for picture %s, %v", id, e)
}
}()
w.Header().Set("Content-Type", imgContentType(id))
w.Header().Set("Content-Length", strconv.Itoa(int(size)))
w.Header().Set("Content-Type", s.imageService.ImgContentType(img))
w.Header().Set("Content-Length", strconv.Itoa(len(img)))
w.WriteHeader(http.StatusOK)
if _, err = io.Copy(w, imgRdr); err != nil {
if _, err = io.Copy(w, bytes.NewReader(img)); err != nil {
log.Printf("[WARN] can't send response to %s, %s", r.RemoteAddr, err)
}
}
// GET /index.html - respond to /index.html with the content of getstarted.html under /web root
func (s *Rest) getStartedCtrl(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadFile(path.Join(s.WebRoot, "getstarted.html"))
func (s *public) getStartedCtrl(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadFile(path.Join(s.webRoot, "getstarted.html"))
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
@@ -392,11 +375,38 @@ func (s *Rest) getStartedCtrl(w http.ResponseWriter, r *http.Request) {
}
// GET /robots.txt
func (s *Rest) getRobotsCtrl(w http.ResponseWriter, r *http.Request) {
allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config",
func (s *public) robotsCtrl(w http.ResponseWriter, r *http.Request) {
allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", "/user",
"/img", "/avatar", "/picture"}
for i := range allowed {
allowed[i] = "Allow: /api/v1" + allowed[i]
}
render.PlainText(w, r, "User-agent: *\nDisallow: /auth/\nDisallow: /api/\n"+strings.Join(allowed, "\n")+"\n")
}
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 {
p := store.Comment{
ID: c.ID,
User: c.User,
}
projection[i] = p
}
return projection
}
return comments
}
func (s *public) parseSince(r *http.Request) (time.Time, error) {
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
}
return sinceTS, nil
}
+227 -115
View File
@@ -9,13 +9,13 @@ import (
"testing"
"time"
cache "github.com/go-pkgz/lcw"
R "github.com/go-pkgz/rest"
"github.com/go-pkgz/rest/cache"
"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) {
@@ -32,11 +32,17 @@ func TestRest_Preview(t *testing.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.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
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)
}
func TestRest_PreviewWithMD(t *testing.T) {
@@ -56,67 +62,96 @@ BKT
text = strings.Replace(text, "BKT", "```", -1)
j := fmt.Sprintf(`{"text": "%s", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
j = strings.Replace(j, "\n", "\\n", -1)
t.Log(j)
resp, err := post(t, ts.URL+"/api/v1/preview", j)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.Equal(t, "<h1>h1</h1>\n\n<pre><code>func TestRest_Preview(t *testing.T) {\nsrv, ts := prep(t)\n require.NotNil(t, srv)\n}\n</code></pre>\n", string(b))
assert.NoError(t, err)
assert.Equal(t,
`<h1>h1</h1>
<pre class="chroma">func TestRest_Preview(t *testing.T) {
srv, ts := prep(t)
require.NotNil(t, srv)
}
</pre>`,
string(b))
}
func TestRest_PreviewCode(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
text := `BKTgo
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 = 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)
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))
}
func TestRest_Find(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
res, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah1")
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
assert.Equal(t, 200, code)
comments := commentsWithInfo{}
err := json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
c1 := store.Comment{Text: "test test #1", ParentID: "",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
id1 := addComment(t, c1, ts)
c2 := store.Comment{Text: "test test #2", ParentID: id1,
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
id2 := addComment(t, c2, ts)
assert.NotEqual(t, id1, id2)
// get sorted by +time
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah1&sort=+time")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&sort=+time")
assert.Equal(t, 200, code)
comments = commentsWithInfo{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.NoError(t, err)
require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, id1, comments.Comments[0].ID)
assert.Equal(t, id2, comments.Comments[1].ID)
assert.Equal(t, "<p>test test #1</p>\n", comments.Comments[0].Text)
assert.Equal(t, "<p>test test #2</p>\n", comments.Comments[1].Text)
assert.Equal(t, "https://radio-t.com/blah1", comments.Info.URL)
assert.Equal(t, 2, comments.Info.Count)
assert.Equal(t, false, comments.Info.ReadOnly)
assert.True(t, comments.Info.FirstTS.Before(comments.Info.LastTS))
// get sorted by -time
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah1&sort=-time")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&sort=-time")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.NoError(t, err)
require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, id1, comments.Comments[1].ID)
assert.Equal(t, id2, comments.Comments[0].ID)
// get in tree mode
tree := service.Tree{}
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah1&format=tree")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &tree)
assert.Nil(t, err)
assert.Equal(t, 1, len(tree.Nodes))
assert.NoError(t, err)
require.Equal(t, 1, len(tree.Nodes))
assert.Equal(t, 1, len(tree.Nodes[0].Replies))
assert.Equal(t, 2, tree.Info.Count)
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
@@ -128,28 +163,28 @@ func TestRest_FindAge(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -5),
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}}
_, err := srv.DataService.Create(c1)
require.Nil(t, err)
require.NoError(t, err)
c2 := store.Comment{Text: "test test #2", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -15),
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}, User: store.User{ID: "u1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}, User: store.User{ID: "u1"}}
_, err = srv.DataService.Create(c2)
require.Nil(t, err)
require.NoError(t, err)
tree := service.Tree{}
res, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah1&format=tree")
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &tree)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
assert.False(t, tree.Info.ReadOnly, "post is fresh")
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah2&format=tree")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah2&format=tree")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &tree)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "https://radio-t.com/blah2", tree.Info.URL)
assert.True(t, tree.Info.ReadOnly, "post is old")
}
@@ -159,90 +194,149 @@ func TestRest_FindReadOnly(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -1),
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}}
_, err := srv.DataService.Create(c1)
require.Nil(t, err)
require.NoError(t, err)
c2 := store.Comment{Text: "test test #2", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -2),
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}, User: store.User{ID: "u1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}, User: store.User{ID: "u1"}}
_, err = srv.DataService.Create(c2)
require.Nil(t, err)
require.NoError(t, err)
// set post to read-only
client := http.Client{}
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah1&ro=1", ts.URL), nil)
assert.Nil(t, err)
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah1&ro=1", ts.URL), nil)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
_, err = client.Do(req)
require.Nil(t, err)
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=radio-t&url=https://radio-t.com/blah1&format=tree")
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &tree)
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
assert.True(t, tree.Info.ReadOnly, "post is ro")
tree = service.Tree{}
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah2&format=tree")
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah2&format=tree")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &tree)
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "https://radio-t.com/blah2", tree.Info.URL)
assert.False(t, tree.Info.ReadOnly, "post is writable")
}
func TestRest_FindUserView(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&view=user")
assert.Equal(t, 200, code)
comments := commentsWithInfo{}
err := json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
c1 := store.Comment{Text: "test test #1", ParentID: "",
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
id1 := addComment(t, c1, ts)
c2 := store.Comment{Text: "test test #2", ParentID: id1,
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
id2 := addComment(t, c2, ts)
assert.NotEqual(t, id1, id2)
// get sorted by +time with view=user
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, 2, len(comments.Comments), "should have 2 comments")
assert.Equal(t, id1, comments.Comments[0].ID)
assert.Equal(t, id2, comments.Comments[1].ID)
assert.Equal(t, "dev", comments.Comments[0].User.ID)
assert.Equal(t, "dev", comments.Comments[1].User.ID)
assert.Equal(t, "", comments.Comments[0].Text)
assert.Equal(t, "", comments.Comments[1].Text)
}
func TestRest_Last(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
res, code := get(t, ts.URL+"/api/v1/last/2?site=radio-t")
res, code := get(t, ts.URL+"/api/v1/last/2?site=remark42")
assert.Equal(t, 200, code)
assert.Equal(t, "[]\n", res, "empty last should return empty list")
c1 := store.Comment{Text: "test test #1", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
// add 3 comments
ts1 := time.Now().UnixNano() / 1000000
addComment(t, c1, ts)
id1 := addComment(t, c1, ts)
time.Sleep(10 * time.Millisecond)
ts2 := time.Now().UnixNano() / 1000000
id2 := addComment(t, c2, ts)
res, code = get(t, ts.URL+"/api/v1/last/2?site=radio-t")
res, code = get(t, ts.URL+"/api/v1/last/2?site=remark42")
assert.Equal(t, 200, code)
comments := []store.Comment{}
err := json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments), "should have 2 comments")
assert.NoError(t, err)
require.Equal(t, 2, len(comments), "should have 2 comments")
assert.Equal(t, id1, comments[1].ID)
assert.Equal(t, id2, comments[0].ID)
res, code = get(t, ts.URL+"/api/v1/last/5?site=radio-t")
res, code = get(t, fmt.Sprintf("%s/api/v1/last/2?site=remark42&since=%d", ts.URL, ts1))
assert.Equal(t, 200, code)
comments = []store.Comment{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
require.Equal(t, 2, len(comments), "should have 2 comments")
assert.Equal(t, id1, comments[1].ID)
assert.Equal(t, id2, comments[0].ID)
res, code = get(t, fmt.Sprintf("%s/api/v1/last/2?site=remark42&since=%d", ts.URL, ts2))
assert.Equal(t, 200, code)
comments = []store.Comment{}
err = json.Unmarshal([]byte(res), &comments)
assert.NoError(t, err)
require.Equal(t, 1, len(comments), "should have 1 comments")
assert.Equal(t, id2, comments[0].ID)
res, code = get(t, ts.URL+"/api/v1/last/5?site=remark42")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 3, len(comments), "should have 3 comments")
res, code = get(t, ts.URL+"/api/v1/last/X?site=radio-t")
res, code = get(t, ts.URL+"/api/v1/last/X?site=remark42")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 3, len(comments), "should have 3 comments")
err = srv.DataService.Delete(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, id1, store.SoftDelete)
assert.Nil(t, err)
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/last/5?site=radio-t")
res, code = get(t, ts.URL+"/api/v1/last/5?site=remark42")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2, len(comments), "should have 2 comments")
t.Logf("%+v", comments)
_, code = get(t, ts.URL+"/api/v1/last/2?site=remark42-BLAH")
assert.Equal(t, 500, code)
}
func TestRest_FindUserComments(t *testing.T) {
@@ -250,9 +344,9 @@ func TestRest_FindUserComments(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #3", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
// add 3 comments
addComment(t, c1, ts)
@@ -264,10 +358,10 @@ func TestRest_FindUserComments(t *testing.T) {
err := srv.DataService.Delete(c2.Locator, id, store.SoftDelete)
assert.NoError(t, err)
_, code := get(t, ts.URL+"/api/v1/comments?site=radio-t&user=blah")
_, 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=radio-t&user=dev")
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev")
assert.Equal(t, 200, code)
resp := struct {
@@ -276,21 +370,26 @@ func TestRest_FindUserComments(t *testing.T) {
}{}
err = json.Unmarshal([]byte(res), &resp)
assert.Nil(t, err)
assert.Equal(t, 3, len(resp.Comments), "should have 3 comments")
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")
// 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))
}
func TestRest_UserInfo(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
body, code := getWithDevAuth(t, ts.URL+"/api/v1/user?site=radio-t")
body, code := getWithDevAuth(t, ts.URL+"/api/v1/user?site=remark42")
assert.Equal(t, 200, code)
user := store.User{}
err := json.Unmarshal([]byte(body), &user)
assert.Nil(t, err)
assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "http://example.com/pic.png", IP: "127.0.0.1"}, 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)
}
func TestRest_Count(t *testing.T) {
@@ -298,9 +397,9 @@ func TestRest_Count(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
addComment(t, c1, ts)
addComment(t, c1, ts)
@@ -308,18 +407,21 @@ func TestRest_Count(t *testing.T) {
addComment(t, c2, ts)
addComment(t, c2, ts)
body, code := get(t, ts.URL+"/api/v1/count?site=radio-t&url=https://radio-t.com/blah1")
body, code := get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah1")
assert.Equal(t, 200, code)
j := R.JSON{}
err := json.Unmarshal([]byte(body), &j)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 3.0, j["count"])
body, code = get(t, ts.URL+"/api/v1/count?site=radio-t&url=https://radio-t.com/blah2")
body, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah2")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(body), &j)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, 2.0, j["count"])
_, code = get(t, ts.URL+"/api/v1/count?site=remark42-BLAH&url=https://radio-t.com/blah1XXX")
assert.Equal(t, 400, code)
}
func TestRest_Counts(t *testing.T) {
@@ -327,9 +429,9 @@ func TestRest_Counts(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
addComment(t, c1, ts)
addComment(t, c1, ts)
@@ -337,18 +439,24 @@ func TestRest_Counts(t *testing.T) {
addComment(t, c2, ts)
addComment(t, c2, ts)
resp, err := post(t, ts.URL+"/api/v1/counts?site=radio-t", `["https://radio-t.com/blah1","https://radio-t.com/blah2"]`)
assert.Nil(t, err)
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)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
j := []store.PostInfo{}
err = json.Unmarshal(body, &j)
assert.Nil(t, err)
assert.Equal(t, []store.PostInfo([]store.PostInfo{{URL: "https://radio-t.com/blah1", Count: 3},
{URL: "https://radio-t.com/blah2", Count: 2}}), j)
assert.NoError(t, err)
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah1", Count: 3},
{URL: "https://radio-t.com/blah2", Count: 2}}, j)
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) {
@@ -356,9 +464,9 @@ func TestRest_List(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
addComment(t, c1, ts)
addComment(t, c1, ts)
@@ -366,15 +474,18 @@ func TestRest_List(t *testing.T) {
addComment(t, c2, ts)
addComment(t, c2, ts)
body, code := get(t, ts.URL+"/api/v1/list?site=radio-t")
body, code := get(t, ts.URL+"/api/v1/list?site=remark42")
assert.Equal(t, 200, code)
pi := []store.PostInfo{}
err := json.Unmarshal([]byte(body), &pi)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "https://radio-t.com/blah2", pi[0].URL)
assert.Equal(t, 2, pi[0].Count)
assert.Equal(t, "https://radio-t.com/blah1", pi[1].URL)
assert.Equal(t, 3, pi[1].Count)
_, code = get(t, ts.URL+"/api/v1/list?site=remark42-BLAH")
assert.Equal(t, 400, code)
}
func TestRest_ListWithSkipAndLimit(t *testing.T) {
@@ -382,11 +493,11 @@ func TestRest_ListWithSkipAndLimit(t *testing.T) {
defer teardown()
c1 := store.Comment{Text: "test test #1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah1"}}
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
c3 := store.Comment{Text: "test test #3", ParentID: "p1",
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah3"}}
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah3"}}
addComment(t, c1, ts)
addComment(t, c1, ts)
@@ -396,11 +507,11 @@ func TestRest_ListWithSkipAndLimit(t *testing.T) {
addComment(t, c3, ts)
addComment(t, c3, ts)
body, code := get(t, ts.URL+"/api/v1/list?site=radio-t&skip=1&limit=2")
body, code := get(t, ts.URL+"/api/v1/list?site=remark42&skip=1&limit=2")
assert.Equal(t, 200, code)
pi := []store.PostInfo{}
err := json.Unmarshal([]byte(body), &pi)
assert.Nil(t, err)
assert.NoError(t, err)
require.Equal(t, 2, len(pi))
assert.Equal(t, "https://radio-t.com/blah2", pi[0].URL)
assert.Equal(t, 2, pi[0].Count)
@@ -412,57 +523,57 @@ func TestRest_Config(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
body, code := get(t, ts.URL+"/api/v1/config?site=radio-t")
body, code := get(t, ts.URL+"/api/v1/config?site=remark42")
assert.Equal(t, 200, code)
j := R.JSON{}
err := json.Unmarshal([]byte(body), &j)
assert.Nil(t, err)
assert.Equal(t, 300., j["edit_duration"])
assert.EqualValues(t, []interface{}([]interface{}{"a1", "a2"}), j["admins"])
assert.NoError(t, err)
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"])
t.Logf("%+v", j)
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))
}
func TestRest_Info(t *testing.T) {
ts, srv, teardown := startupT(t)
defer teardown()
srv.ReadOnlyAge = 10000000 // make sure we don't hit read-only
srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only
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)}
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)}
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)}
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
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, 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, 5, 27, 1, 14, 25, 0, time.Local)}
_, err := srv.DataService.Create(c1)
require.Nil(t, err, "%+v", err)
require.NoError(t, err, "%+v", err)
_, err = srv.DataService.Create(c2)
require.Nil(t, err)
require.NoError(t, err)
_, err = srv.DataService.Create(c3)
require.Nil(t, err)
require.NoError(t, err)
body, code := get(t, ts.URL+"/api/v1/info?site=radio-t&url=https://radio-t.com/blah1")
body, code := get(t, ts.URL+"/api/v1/info?site=remark42&url=https://radio-t.com/blah1")
assert.Equal(t, 200, code)
info := store.PostInfo{}
err = json.Unmarshal([]byte(body), &info)
assert.Nil(t, err)
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=radio-t&url=https://radio-t.com/blah-no")
_, code = get(t, ts.URL+"/api/v1/info?site=remark42&url=https://radio-t.com/blah-no")
assert.Equal(t, 400, code)
_, code = get(t, ts.URL+"/api/v1/info?site=radio-t-no&url=https://radio-t.com/blah-no")
_, code = get(t, ts.URL+"/api/v1/info?site=remark42-no&url=https://radio-t.com/blah-no")
assert.Equal(t, 400, code)
}
@@ -474,5 +585,6 @@ func TestRest_Robots(t *testing.T) {
assert.Equal(t, 200, code)
assert.Equal(t, "User-agent: *\nDisallow: /auth/\nDisallow: /api/\nAllow: /api/v1/find\n"+
"Allow: /api/v1/last\nAllow: /api/v1/id\nAllow: /api/v1/count\nAllow: /api/v1/counts\n"+
"Allow: /api/v1/list\nAllow: /api/v1/config\nAllow: /api/v1/img\nAllow: /api/v1/avatar\nAllow: /api/v1/picture\n", string(body))
"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)
}
+247 -94
View File
@@ -4,9 +4,11 @@ import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"math/rand"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -15,47 +17,57 @@ import (
"testing"
"time"
bolt "github.com/coreos/bbolt"
"github.com/go-pkgz/auth"
"github.com/go-pkgz/auth/avatar"
"github.com/go-pkgz/auth/token"
cache "github.com/go-pkgz/lcw"
R "github.com/go-pkgz/rest"
"github.com/go-pkgz/rest/cache"
"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/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 testHTML = "/tmp/test-remark.html"
var getStartedHTML = "/tmp/getstarted.html"
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg`
var devToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg"
var anonToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImFub255bW91cyB0ZXN0IHVzZXIiLCJpZCI6ImFub255bW91c190ZXN0X3VzZXIiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoiYW5vbkBleGFtcGxlLmNvbSJ9fQ.gAae2WMxZNZE5ebVboptPEyQ7Nk6EQxciNnGJ_mPOuU`
var adminUmputunToken = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyYWRpb3QiLCJleHAiOjE5NTQ1OTc5ODAsImp0aSI6Ijk3YTJlMGFjNGRjN2Q1ZjY5MjZkNWU4NjIwYWNlZjlhNDBjMCIsImlhdCI6MTQ1NDU5NzY4MCwiaXNzIjoicmVtYXJrNDIiLCJ1c2VyIjp7Im5hbWUiOiJVbXB1dHVuIiwiaWQiOiJnaXRodWJfZWYwZjcwNmE3IiwicGljdHVyZSI6Imh0dHBzOi8vcmVtYXJrNDIucmFkaW8tdC5jb20vYXBpL3YxL2F2YXRhci9jYjQyZmY0OTNhZGU2OTZkODhhM2E1OTBmMTM2YWU5ZTM0ZGU3YzFiLmltYWdlIiwiYXR0cnMiOnsiYWRtaW4iOnRydWUsImJsb2NrZWQiOmZhbHNlfX19.gAR_sZT7hTx7CNHByyrJQWMB5tAtoiISAiG8kes1IjA"
var devTokenBadAud = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0Ml9iYWQiLCJleHAiOjM3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTIxODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJkZXYiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoibWVAZXhhbXBsZS5jb20ifX0.FuTTocVtcxr4VjpfIICvU2yOb3su28VkDzj94H9Q3xY`
var adminUmputunToken = `eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6MTk1NDU5Nzk4MCwianRpIjoiOTdhMmUwYWM0ZGM3ZDVmNjkyNmQ1ZTg2MjBhY2VmOWE0MGMwIiwiaWF0IjoxNDU0NTk3NjgwLCJpc3MiOiJyZW1hcms0MiIsInVzZXIiOnsibmFtZSI6IlVtcHV0dW4iLCJpZCI6ImdpdGh1Yl9lZjBmNzA2YTciLCJwaWN0dXJlIjoiaHR0cHM6Ly9yZW1hcms0Mi5yYWRpby10LmNvbS9hcGkvdjEvYXZhdGFyL2NiNDJmZjQ5M2FkZTY5NmQ4OGEzYTU5MGYxMzZhZTllMzRkZTdjMWIuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.dZiOjWHguo9f42XCMooMcv4EmYFzifl_-LEvPZHCtks`
func TestRest_FileServer(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
body, code := get(t, ts.URL+"/web/test-remark.html")
testHTMLName := "test-remark.html"
testHTMLFile := os.TempDir() + "/" + testHTMLName
err := ioutil.WriteFile(testHTMLFile, []byte("some html"), 0700)
assert.NoError(t, err)
body, code := get(t, ts.URL+"/web/"+testHTMLName)
assert.Equal(t, 200, code)
assert.Equal(t, "some html", body)
_ = os.Remove(testHTMLFile)
}
func TestRest_GetStarted(t *testing.T) {
ts, _, teardown := startupT(t)
defer teardown()
getStartedHTML := os.TempDir() + "/getstarted.html"
err := ioutil.WriteFile(getStartedHTML, []byte("some html blah"), 0700)
assert.Nil(t, err)
assert.NoError(t, err)
body, code := get(t, ts.URL+"/index.html")
assert.Equal(t, 200, code)
@@ -69,25 +81,30 @@ func TestRest_GetStarted(t *testing.T) {
func TestRest_Shutdown(t *testing.T) {
srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}}
done := make(chan bool)
// without waiting for channel close at the end goroutine will stay alive after test finish
// which would create data race with next test
go func() {
time.Sleep(100 * time.Millisecond)
time.Sleep(200 * time.Millisecond)
srv.Shutdown()
close(done)
}()
st := time.Now()
srv.Run(0)
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms")
<-done
}
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"
@@ -96,6 +113,7 @@ func TestRest_filterComments(t *testing.T) {
}
func TestRest_RunStaticSSLMode(t *testing.T) {
sslPort := chooseRandomUnusedPort()
srv := Rest{
Authenticator: auth.NewService(auth.Opts{
AvatarStore: avatar.NewLocalFS("/tmp"),
@@ -105,18 +123,19 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
ImageProxy: &proxy.Image{},
SSLConfig: SSLConfig{
SSLMode: Static,
Port: 8443,
Port: sslPort,
Key: "../../cmd/testdata/key.pem",
Cert: "../../cmd/testdata/cert.pem",
},
RemarkURL: "https://localhost:8443",
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
}
port := chooseRandomUnusedPort()
go func() {
srv.Run(38080)
srv.Run(port)
}()
time.Sleep(100 * time.Millisecond) // let server start
waitForHTTPSServerStart(sslPort)
client := http.Client{
// prevent http redirect
@@ -130,40 +149,42 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
},
}
resp, err := client.Get("http://localhost:38080/blah?param=1")
require.Nil(t, err)
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/blah?param=1", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 307, resp.StatusCode)
assert.Equal(t, "https://localhost:8443/blah?param=1", resp.Header.Get("Location"))
assert.Equal(t, fmt.Sprintf("https://localhost:%d/blah?param=1", sslPort), resp.Header.Get("Location"))
resp, err = client.Get("https://localhost:8443/ping")
require.Nil(t, err)
resp, err = client.Get(fmt.Sprintf("https://localhost:%d/ping", sslPort))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "pong", string(body))
srv.Shutdown()
}
func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
sslPort := chooseRandomUnusedPort()
srv := Rest{
Authenticator: &auth.Service{},
ImageProxy: &proxy.Image{},
SSLConfig: SSLConfig{
SSLMode: Auto,
Port: 8443,
Port: sslPort,
},
RemarkURL: "https://localhost:8443",
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
}
port := chooseRandomUnusedPort()
go func() {
// can't check https server locally, just only http server
srv.Run(38081)
srv.Run(port)
}()
time.Sleep(100 * time.Millisecond) // let server start
waitForHTTPSServerStart(sslPort)
client := http.Client{
// prevent http redirect
@@ -172,11 +193,11 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
},
}
resp, err := client.Get("http://localhost:38081/blah?param=1")
require.Nil(t, err)
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/blah?param=1", port))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 307, resp.StatusCode)
assert.Equal(t, "https://localhost:8443/blah?param=1", resp.Header.Get("Location"))
assert.Equal(t, fmt.Sprintf("https://localhost:%d/blah?param=1", sslPort), resp.Header.Get("Location"))
srv.Shutdown()
}
@@ -190,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")
}
@@ -213,6 +237,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)
require.NoError(t, err)
@@ -238,6 +263,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)
require.NoError(t, err)
@@ -249,28 +275,127 @@ func Test_URLKeyWithUser(t *testing.T) {
}
}
func TestRest_parseError(t *testing.T) {
tbl := []struct {
err error
res int
}{
{errors.New("can not vote for his own comment"), rest.ErrVoteSelf},
{errors.New("already voted for"), rest.ErrVoteDbl},
{errors.New("maximum number of votes exceeded for comment"), rest.ErrVoteMax},
{errors.New("minimal score reached for comment"), rest.ErrVoteMinScore},
{errors.New("too late to edit"), rest.ErrCommentEditExpired},
{errors.New("parent comment with reply can't be edited"), rest.ErrCommentEditChanged},
{errors.New("blah blah"), rest.ErrInternal},
}
for n, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(n), func(t *testing.T) {
res := parseError(tt.err, rest.ErrInternal)
assert.Equal(t, tt.res, res)
})
}
}
func TestRest_cacheControl(t *testing.T) {
tbl := []struct {
url string
version string
exp time.Duration
etag string
maxAge int
}{
{"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600},
{"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000},
{"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600},
{"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600},
}
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, nil)
w := httptest.NewRecorder()
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
h.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("%+v", resp.Header)
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
})
}
}
func TestRest_frameAncestors(t *testing.T) {
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)
t.Logf("%+v", resp.Header)
assert.Equal(t, tt.header, resp.Header.Get("Content-Security-Policy"))
})
}
}
// 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++ {
fname := fmt.Sprintf("/%s/%s-%d%s", tempDir, basename, rand.Int31(), suffix)
fmt.Printf("fname %q", fname)
_, err := os.Stat(fname)
if err != nil {
return fname, nil
}
}
return "", 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)
testDb := fmt.Sprintf("/tmp/test-remark-%d.db", rand.Int31())
os.Remove(testDb)
os.Remove(testHTML)
os.RemoveAll("/tmp/ava-remark42")
os.RemoveAll("/tmp/pics-remark42")
_ = os.RemoveAll(tmp + "/ava-remark42")
_ = os.RemoveAll(tmp + "/pics-remark42")
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDb, SiteID: "radio-t"})
require.Nil(t, err)
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDB, SiteID: "remark42"})
require.NoError(t, err)
memCache, err := cache.NewMemoryCache()
assert.NoError(t, err)
memCache := cache.NewScache(cache.NewNopCache())
adminStore := adminstore.NewStaticStore("123456", []string{"a1", "a2"}, "admin@remark-42.com")
astore := adminstore.NewStaticStore("123456", []string{"remark42"}, []string{"a1", "a2"}, "admin@remark-42.com")
restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}})
dataStore := &service.DataStore{
Interface: b,
Engine: b,
EditDuration: 5 * time.Minute,
MaxCommentSize: 4000,
AdminStore: adminStore,
AdminStore: astore,
MaxVotes: service.UnlimitedVotes,
RestrictedWordsMatcher: restrictedWordsMatcher,
}
@@ -279,21 +404,20 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
DataService: dataStore,
Authenticator: auth.NewService(auth.Opts{
AdminPasswd: "password",
SecretReader: token.SecretFunc(func() (string, error) { return "secret", nil }),
AvatarStore: avatar.NewLocalFS("/tmp/ava-remark42"),
SecretReader: token.SecretFunc(func(aud string) (string, error) { return "secret", nil }),
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
}),
Cache: memCache,
WebRoot: "/tmp",
WebRoot: tmp,
RemarkURL: "https://demo.remark42.com",
ImageService: &image.Service{
Store: &image.FileSystem{
Location: "/tmp/pics-remark42",
Partitions: 100,
MaxSize: 10000,
Staging: "/tmp/pics-remark42/staging",
},
TTL: time.Millisecond * 100,
},
ImageService: image.NewService(&image.FileSystem{
Location: tmp + "/pics-remark42",
Partitions: 100,
Staging: tmp + "/pics-remark42/staging",
}, image.ServiceParams{
EditDuration: 100 * time.Millisecond,
MaxSize: 10000,
}),
ImageProxy: &proxy.Image{},
ReadOnlyAge: 10,
CommentFormatter: store.NewCommentFormatter(&proxy.Image{}),
@@ -302,30 +426,29 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
WordPressImporter: &migrator.WordPress{DataStore: dataStore},
NativeImporter: &migrator.Native{DataStore: dataStore},
NativeExporter: &migrator.Native{DataStore: dataStore},
Cache: &cache.Nop{},
KeyStore: adminStore,
URLMapperMaker: migrator.NewURLMapper,
Cache: memCache,
KeyStore: astore,
},
NotifyService: notify.NopService,
EmojiEnabled: true,
}
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
err = ioutil.WriteFile(testHTML, []byte("some html"), 0700)
assert.Nil(t, err)
ts = httptest.NewServer(srv.routes())
teardown = func() {
ts.Close()
require.NoError(t, srv.DataService.Close())
os.Remove(testDb)
os.Remove(testHTML)
os.RemoveAll("/tmp/ava-remark42")
os.RemoveAll("/tmp/pics-remark42")
_ = os.Remove(testDB)
_ = os.RemoveAll(tmp + "/ava-remark42")
_ = os.RemoveAll(tmp + "/pics-remark42")
}
return ts, srv, teardown
}
// fake auth middleware make user authed and uses query's fake_id for ID and fake_name for Name
// fake auth middleware make user authenticated and uses query's fake_id for ID and fake_name for Name
func fakeAuth(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("fake_id") != "" {
@@ -339,19 +462,19 @@ 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.Nil(t, err)
defer r.Body.Close()
require.NoError(t, err)
body, err := ioutil.ReadAll(r.Body)
require.Nil(t, err)
require.NoError(t, err)
require.NoError(t, r.Body.Close())
return string(body), r.StatusCode
}
func sendReq(t *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)
}
@@ -359,53 +482,54 @@ func sendReq(t *testing.T, r *http.Request, token string) (*http.Response, error
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)
require.Nil(t, err)
require.NoError(t, err)
req.Header.Add("X-JWT", devToken)
r, err := client.Do(req)
require.Nil(t, err)
defer r.Body.Close()
require.NoError(t, err)
b, err := ioutil.ReadAll(r.Body)
assert.Nil(t, err)
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)
require.Nil(t, err)
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
r, err := client.Do(req)
require.Nil(t, err)
defer r.Body.Close()
require.NoError(t, err)
body, err := ioutil.ReadAll(r.Body)
assert.Nil(t, err)
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.Nil(t, err)
assert.NoError(t, err)
req.SetBasicAuth("admin", "password")
return client.Do(req)
}
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
b, err := json.Marshal(c)
require.Nil(t, err, "can't marshal comment %+v", c)
require.NoError(t, err, "can't marshal comment %+v", c)
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
require.Nil(t, err)
require.NoError(t, err)
req.Header.Add("X-JWT", devToken)
resp, err := client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
b, err = ioutil.ReadAll(resp.Body)
require.Nil(t, err)
require.NoError(t, resp.Body.Close())
require.NoError(t, err)
crResp := R.JSON{}
err = json.Unmarshal(b, &crResp)
require.Nil(t, err)
require.NoError(t, err)
time.Sleep(time.Nanosecond * 10)
return crResp["id"].(string)
}
@@ -413,9 +537,38 @@ 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)
}
func chooseRandomUnusedPort() (port int) {
for i := 0; i < 10; i++ {
port = 40000 + int(rand.Int31n(10000))
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
_ = ln.Close()
break
}
}
return port
}
func waitForHTTPSServerStart(port int) {
// wait for up to 3 seconds for HTTPS server to start
for i := 0; i < 300; i++ {
time.Sleep(time.Millisecond * 10)
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
if conn != nil {
_ = conn.Close()
break
}
}
}
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
+34 -43
View File
@@ -5,39 +5,49 @@ import (
"net/http"
"time"
cache "github.com/go-pkgz/lcw"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/rest/cache"
"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 {
dataService rssStore
cache LoadingCache
}
type rssStore interface {
Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error)
Last(siteID string, limit int, since time.Time, user store.User) ([]store.Comment, error)
Get(locator store.Locator, commentID string, user store.User) (store.Comment, error)
UserReplies(siteID, userID string, limit int, duration time.Duration) ([]store.Comment, string, error)
}
const maxRssItems = 20
const maxLastCommentsReply = 5000
const maxReplyDuration = 31 * 24 * time.Hour
// ui uses links like <post-url>#remark42__comment-<comment-id>
const uiNav = "#remark42__comment-"
// GET /rss/post?site=siteID&url=post-url
func (s *Rest) rssPostCommentsCtrl(w http.ResponseWriter, r *http.Request) {
func (s *rss) postCommentsCtrl(w http.ResponseWriter, r *http.Request) {
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
log.Printf("[DEBUG] get rss for post %+v", locator)
key := cache.NewKey(locator.SiteID).ID(URLKey(r)).Scopes(locator.SiteID, locator.URL)
data, err := s.Cache.Get(key, func() ([]byte, error) {
comments, e := s.DataService.Find(locator, "-time")
data, err := s.cache.Get(key, func() ([]byte, error) {
comments, e := s.dataService.Find(locator, "-time", rest.GetUserOrEmpty(r))
if e != nil {
return nil, e
}
comments = s.alterComments(comments, r)
rss, e := s.toRssFeed(locator.URL, comments, "post comments for "+r.URL.Query().Get("url"))
feed, e := s.toRssFeed(locator.URL, comments, "post comments for "+r.URL.Query().Get("url"))
if e != nil {
return nil, e
}
return []byte(rss), e
return []byte(feed), e
})
if err != nil {
@@ -54,23 +64,22 @@ func (s *Rest) rssPostCommentsCtrl(w http.ResponseWriter, r *http.Request) {
}
// GET /rss/site?site=siteID
func (s *Rest) rssSiteCommentsCtrl(w http.ResponseWriter, r *http.Request) {
func (s *rss) siteCommentsCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] get rss for site %s", siteID)
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID, lastCommentsScope)
data, err := s.Cache.Get(key, func() ([]byte, error) {
comments, e := s.DataService.Last(siteID, maxRssItems)
data, err := s.cache.Get(key, func() ([]byte, error) {
comments, e := s.dataService.Last(siteID, maxRssItems, time.Time{}, rest.GetUserOrEmpty(r))
if e != nil {
return nil, e
}
comments = s.alterComments(comments, r)
rss, e := s.toRssFeed(r.URL.Query().Get("site"), comments, "site comment for "+siteID)
feed, e := s.toRssFeed(r.URL.Query().Get("site"), comments, "site comment for "+siteID)
if e != nil {
return nil, e
}
return []byte(rss), e
return []byte(feed), e
})
if err != nil {
@@ -86,43 +95,24 @@ func (s *Rest) rssSiteCommentsCtrl(w http.ResponseWriter, r *http.Request) {
}
// GET /rss/reply?user=userID&site=siteID
func (s *Rest) rssRepliesCtrl(w http.ResponseWriter, r *http.Request) {
func (s *rss) repliesCtrl(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user")
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] get rss replies to user %s for site %s", userID, siteID)
userName := ""
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID, lastCommentsScope)
data, err := s.Cache.Get(key, func() (res []byte, e error) {
comments, e := s.DataService.Last(siteID, maxLastCommentsReply)
data, err := s.cache.Get(key, func() (res []byte, e error) {
replies, userName, e := s.dataService.UserReplies(siteID, userID, maxRssItems, maxReplyDuration)
if e != nil {
return nil, errors.Wrap(e, "can't get last comments")
}
comments = s.alterComments(comments, r)
replies := []store.Comment{}
for _, c := range comments {
if len(replies) > maxRssItems || c.Timestamp.Add(maxReplyDuration).Before(time.Now()) {
break
}
if c.User.ID != userID {
userName = c.User.Name
}
if c.ParentID != "" && !c.Deleted && c.User.ID != userID { // not interested in replies to yourself
var pc store.Comment
if pc, e = s.DataService.Get(c.Locator, c.ParentID); e != nil {
return nil, errors.Wrap(e, "can't get parent comment")
}
if pc.User.ID == userID {
replies = append(replies, c)
}
}
}
rss, e := s.toRssFeed(siteID, replies, "replies to "+userName)
feed, e := s.toRssFeed(siteID, replies, "replies to "+userName)
if e != nil {
return nil, e
}
return []byte(rss), e
return []byte(feed), e
})
if err != nil {
@@ -137,7 +127,7 @@ func (s *Rest) rssRepliesCtrl(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Rest) toRssFeed(url string, comments []store.Comment, description string) (string, error) {
func (s *rss) toRssFeed(url string, comments []store.Comment, description string) (string, error) {
if description == "" {
description = "comment updates"
@@ -166,9 +156,10 @@ func (s *Rest) toRssFeed(url string, comments []store.Comment, description strin
}
if c.ParentID != "" {
// add indication to parent comment
parentComment, err := s.DataService.Get(c.Locator, c.ParentID)
parentComment, err := s.dataService.Get(c.Locator, c.ParentID, store.User{})
if err == nil {
f.Title = fmt.Sprintf("%s > %s", c.User.Name, parentComment.User.Name)
f.Description = f.Description + "<blockquote><p>" + parentComment.Snippet(300) + "</p></blockquote>"
} else {
log.Printf("[WARN] failed to get info about parent comment, %s", err)
}
+25 -25
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) {
@@ -22,7 +22,7 @@ func TestServer_RssPost(t *testing.T) {
c1 := store.Comment{
ID: "1234567890",
Text: "test 123",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
}
id1, err := rst.DataService.Create(c1)
@@ -30,7 +30,7 @@ func TestServer_RssPost(t *testing.T) {
assert.Equal(t, "1234567890", id1)
pubDate := time.Now().Format(time.RFC1123Z)
res, code := get(t, ts.URL+"/api/v1/rss/post?site=radio-t&url=https://radio-t.com/blah1")
res, code := get(t, ts.URL+"/api/v1/rss/post?site=remark42&url=https://radio-t.com/blah1")
assert.Equal(t, 200, code)
t.Log(res)
@@ -54,7 +54,7 @@ func TestServer_RssPost(t *testing.T) {
expected, res = cleanRssFormatting(expected, res)
assert.Equal(t, expected, res)
_, code = get(t, ts.URL+"/api/v1/rss/post?site=radio-t-bad&url=https://radio-t.com/blah1")
_, code = get(t, ts.URL+"/api/v1/rss/post?site=remark42-bad&url=https://radio-t.com/blah1")
assert.Equal(t, 400, code)
}
@@ -69,13 +69,13 @@ func TestServer_RssSite(t *testing.T) {
c1 := store.Comment{
ID: "comment-id-1",
Text: "test 123",
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
}
c2 := store.Comment{
ID: "comment-id-2",
Text: "xyz test",
Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
}
@@ -85,15 +85,15 @@ func TestServer_RssSite(t *testing.T) {
require.NoError(t, err)
require.NoError(t, err)
res, code := get(t, ts.URL+"/api/v1/rss/site?site=radio-t")
res, code := get(t, ts.URL+"/api/v1/rss/site?site=remark42")
assert.Equal(t, 200, code)
t.Log(res)
expected := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Remark42 comments</title>
<link>radio-t</link>
<description>site comment for radio-t</description>
<link>remark42</link>
<description>site comment for remark42</description>
<pubDate>%s</pubDate>
<item>
<title>developer one</title>
@@ -132,14 +132,14 @@ func TestServer_RssWithReply(t *testing.T) {
c1 := store.Comment{
ID: "comment-id-1",
Text: "test 123",
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
}
c2 := store.Comment{
ID: "comment-id-2",
ParentID: "comment-id-1",
Text: "xyz test",
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
User: store.User{ID: "u1", Name: "developer one"},
}
@@ -148,7 +148,7 @@ func TestServer_RssWithReply(t *testing.T) {
_, err = rst.DataService.Create(c2)
require.NoError(t, err)
res, code := get(t, ts.URL+"/api/v1/rss/post?site=radio-t&url=https://radio-t.com/blah10")
res, code := get(t, ts.URL+"/api/v1/rss/post?site=remark42&url=https://radio-t.com/blah10")
assert.Equal(t, 200, code)
t.Log(res)
@@ -161,7 +161,7 @@ func TestServer_RssWithReply(t *testing.T) {
<item>
<title>developer one &gt; developer one</title>
<link>https://radio-t.com/blah10#remark42__comment-comment-id-2</link>
<description>xyz test</description>
<description>xyz test&lt;blockquote&gt;&lt;p&gt;test 123&lt;/p&gt;&lt;/blockquote&gt;</description>
<author>developer one</author>
<guid>comment-id-2</guid>
<pubDate>%s</pubDate>
@@ -192,34 +192,34 @@ func TestServer_RssReplies(t *testing.T) {
c1 := store.Comment{
ID: "comment-1",
Text: "c1",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "user1", Name: "user1"},
}
c2 := store.Comment{
ID: "comment-2",
Text: "reply to c1 from user2",
ParentID: "comment-1",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "user2", Name: "user2"},
}
c3 := store.Comment{
ID: "comment-3",
Text: "reply to c1 from user3",
ParentID: "comment-1",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "user3", Name: "user3"},
}
c4 := store.Comment{
ID: "comment-4",
Text: "reply to c2 from developer one",
ParentID: "comment-2",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "dev", Name: "developer one"},
}
c5 := store.Comment{
ID: "comment-5",
Text: "developer one",
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "radio-t"},
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
User: store.User{ID: "dev", Name: "developer one"},
}
@@ -235,19 +235,19 @@ func TestServer_RssReplies(t *testing.T) {
require.NoError(t, err)
// replies to c1 (user1). Must be [c3, c2]
res, code := get(t, ts.URL+"/api/v1/rss/reply?user=user1&site=radio-t")
res, code := get(t, ts.URL+"/api/v1/rss/reply?user=user1&site=remark42")
assert.Equal(t, 200, code)
t.Log(res)
expected := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Remark42 comments</title>
<link>radio-t</link>
<description>replies to user2</description>
<link>remark42</link>
<description>replies to user1</description>
<pubDate>%s</pubDate>
<item>
<title>user3 &gt; user1</title>
<link>https://radio-t.com/blah1#remark42__comment-comment-3</link>
<description>reply to c1 from user3</description>
<description>reply to c1 from user3&lt;blockquote&gt;&lt;p&gt;c1&lt;/p&gt;&lt;/blockquote&gt;</description>
<author>user3</author>
<guid>comment-3</guid>
<pubDate>%s</pubDate>
@@ -255,7 +255,7 @@ func TestServer_RssReplies(t *testing.T) {
<item>
<title>user2 &gt; user1</title>
<link>https://radio-t.com/blah1#remark42__comment-comment-2</link>
<description>reply to c1 from user2</description>
<description>reply to c1 from user2&lt;blockquote&gt;&lt;p&gt;c1&lt;/p&gt;&lt;/blockquote&gt;</description>
<author>user2</author>
<guid>comment-2</guid>
<pubDate>%s</pubDate>
@@ -265,7 +265,7 @@ func TestServer_RssReplies(t *testing.T) {
expected, res = cleanRssFormatting(expected, res)
assert.Equal(t, expected, res)
_, code = get(t, ts.URL+"/api/v1/rss/reply?user=user1&site=radio-t-bad")
_, code = get(t, ts.URL+"/api/v1/rss/reply?user=user1&site=remark42-bad")
assert.Equal(t, 400, code)
}
@@ -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"
@@ -86,7 +86,7 @@ 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)
cfg := makeTLSConfig()
cfg := s.makeTLSConfig()
cfg.GetCertificate = m.GetCertificate
server.TLSConfig = cfg
return server
@@ -95,7 +95,7 @@ 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)
server.TLSConfig = makeTLSConfig()
server.TLSConfig = s.makeTLSConfig()
return server
}
@@ -109,7 +109,7 @@ func (s *Rest) getRemarkHost() string {
return u.Hostname()
}
func makeTLSConfig() *tls.Config {
func (s *Rest) makeTLSConfig() *tls.Config {
return &tls.Config{
PreferServerCipherSuites: true,
CipherSuites: []uint16{
@@ -119,7 +119,7 @@ func makeTLSConfig() *tls.Config {
// tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
// tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
},
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
+7 -7
View File
@@ -33,7 +33,7 @@ func TestSSL_Redirect(t *testing.T) {
// check http to https redirect response
resp, err := client.Get(ts.URL + "/blah?param=1")
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 307, resp.StatusCode)
assert.Equal(t, "https://localhost:443/blah?param=1", resp.Header.Get("Location"))
@@ -62,28 +62,28 @@ func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
// check http to https redirect response
resp, err := client.Get(ts.URL + "/blah?param=1")
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 307, resp.StatusCode)
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)
require.Nil(t, err)
require.NoError(t, err)
req.Host = "localhost" // for passing hostPolicy check
resp, err = client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 404, resp.StatusCode)
err = m.Cache.Put(context.Background(), "token123+http-01", []byte("token"))
assert.Nil(t, err)
assert.NoError(t, err)
resp, err = client.Do(req)
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, "token", string(body))
}
+43 -8
View File
@@ -1,7 +1,10 @@
package rest
import (
"bytes"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"runtime"
@@ -10,6 +13,8 @@ import (
"github.com/go-chi/render"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/rest"
"github.com/umputun/remark42/backend/app/templates"
)
// All error codes for UI mapping and translation
@@ -35,9 +40,43 @@ const (
ErrAssetNotFound = 18 // requested file not found
)
// errTmplData store data for error message
type errTmplData struct {
Error string
Details string
}
// SendErrorHTML makes html body with provided template and responds with provided http status code,
// error code is not included in render as it is intended for UI developers and not for the users
func SendErrorHTML(w http.ResponseWriter, r *http.Request, httpStatusCode int, err error, details string, errCode int, t templates.FileReader) {
// MustExecute behaves like template.Execute, but panics if an error occurs.
MustExecute := func(tmpl *template.Template, wr io.Writer, data interface{}) {
if err = tmpl.Execute(wr, data); err != nil {
panic(err)
}
}
MustRead := func(path string) string {
file, e := t.ReadFile(path)
if e != nil {
panic(e)
}
return string(file)
}
tmplstr := MustRead("error_response.html.tmpl")
tmpl := template.Must(template.New("error").Parse(tmplstr))
log.Printf("[WARN] %s", errDetailsMsg(r, httpStatusCode, err, details, errCode))
render.Status(r, httpStatusCode)
msg := bytes.Buffer{}
MustExecute(tmpl, &msg, errTmplData{
Error: err.Error(),
Details: details,
})
render.HTML(w, r, msg.String())
}
// SendErrorJSON makes {error: blah, details: blah} json body and responds with error code
func SendErrorJSON(w http.ResponseWriter, r *http.Request, httpStatusCode int, err error, details string, errCode int) {
log.Printf("[DEBUG] %s", errDetailsMsg(r, httpStatusCode, err, details, errCode))
log.Printf("[WARN] %s", errDetailsMsg(r, httpStatusCode, err, details, errCode))
render.Status(r, httpStatusCode)
render.JSON(w, r, rest.JSON{"error": err.Error(), "details": details, "code": errCode})
}
@@ -56,14 +95,10 @@ func errDetailsMsg(r *http.Request, httpStatusCode int, err error, details strin
if pc, file, line, ok := runtime.Caller(2); ok {
fnameElems := strings.Split(file, "/")
funcNameElems := strings.Split(runtime.FuncForPC(pc).Name(), "/")
srcFileInfo = fmt.Sprintf(" [caused by %s:%d %s]", strings.Join(fnameElems[len(fnameElems)-3:], "/"),
srcFileInfo = fmt.Sprintf("[%s:%d %s]", strings.Join(fnameElems[len(fnameElems)-3:], "/"),
line, funcNameElems[len(funcNameElems)-1])
}
remoteIP := r.RemoteAddr
if pos := strings.Index(remoteIP, ":"); pos >= 0 {
remoteIP = remoteIP[:pos]
}
return fmt.Sprintf("%s - %v - %d (%d) - %s%s - %s%s",
details, err, httpStatusCode, errCode, uinfoStr, remoteIP, q, srcFileInfo)
return fmt.Sprintf("%s - %v - %d (%d) - %s%s - %s",
details, err, httpStatusCode, errCode, uinfoStr, q, srcFileInfo)
}
+46 -7
View File
@@ -2,6 +2,7 @@ package rest
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
@@ -9,7 +10,8 @@ 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 TestSendErrorJSON(t *testing.T) {
@@ -26,23 +28,57 @@ func TestSendErrorJSON(t *testing.T) {
defer ts.Close()
resp, err := http.Get(ts.URL + "/error")
require.Nil(t, err)
require.NoError(t, err)
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
require.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, 500, resp.StatusCode)
assert.Equal(t, `{"code":123,"details":"error details 123456","error":"error 500"}`+"\n", string(body))
}
type MockFS struct{}
func (fs *MockFS) ReadFile(path string) ([]byte, error) {
return []byte(fmt.Sprintf("{{.Error}}{{.Details}} %s", path)), nil
}
func TestSendErrorHTML(t *testing.T) {
fs := &MockFS{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/error" {
t.Log("http err request", r.URL)
SendErrorHTML(w, r, 500, errors.New("error 500"), "error details 123456", 987, fs)
return
}
w.WriteHeader(404)
}))
defer ts.Close()
resp, err := http.Get(ts.URL + "/error")
require.NoError(t, err)
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, 500, resp.StatusCode)
assert.NotContains(t, string(body), `987`, "user html should not contain internal error code")
assert.Contains(t, string(body), `error details 123456`)
assert.Contains(t, string(body), `error 500`)
}
func TestErrorDetailsMsg(t *testing.T) {
callerFn := func() {
req, err := http.NewRequest("GET", "https://example.com/test?k1=v1&k2=v2", nil)
require.Nil(t, err)
require.NoError(t, err)
req.RemoteAddr = "1.2.3.4"
msg := errDetailsMsg(req, 500, errors.New("error 500"), "error details 123456", 123)
assert.Equal(t, "error details 123456 - error 500 - 500 (123) - 1.2.3.4 - https://example.com/test?k1=v1&k2=v2 [caused by app/rest/httperrors_test.go:47 rest.TestErrorDetailsMsg]", msg)
assert.Contains(t, msg, "error details 123456 - error 500 - 500 (123) - https://example.com/test?k1=v1&k2=v2 - [app/rest/httperrors_test.go:")
// error line in the middle of the message is not checked
assert.Contains(t, msg, " rest.TestErrorDetailsMsg]")
}
callerFn()
}
@@ -50,11 +86,14 @@ func TestErrorDetailsMsg(t *testing.T) {
func TestErrorDetailsMsgWithUser(t *testing.T) {
callerFn := func() {
req, err := http.NewRequest("GET", "https://example.com/test?k1=v1&k2=v2", nil)
require.NoError(t, err)
req.RemoteAddr = "127.0.0.1:1234"
req = SetUserInfo(req, store.User{Name: "test", ID: "id"})
require.Nil(t, err)
require.NoError(t, err)
msg := errDetailsMsg(req, 500, errors.New("error 500"), "error details 123456", 34567)
assert.Equal(t, "error details 123456 - error 500 - 500 (34567) - test/id - 127.0.0.1 - https://example.com/test?k1=v1&k2=v2 [caused by app/rest/httperrors_test.go:59 rest.TestErrorDetailsMsgWithUser]", msg)
assert.Contains(t, msg, "error details 123456 - error 500 - 500 (34567) - test/id - https://example.com/test?k1=v1&k2=v2 - [app/rest/httperrors_test.go:")
// error line in the middle of the message is not checked
assert.Contains(t, msg, " rest.TestErrorDetailsMsgWithUser]")
}
callerFn()
}
+110 -78
View File
@@ -1,9 +1,11 @@
package proxy
import (
"bytes"
"context"
"encoding/base64"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
@@ -13,88 +15,98 @@ import (
"github.com/go-pkgz/repeater"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/rest"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store/image"
)
// Image extracts image src from comment's html and provides proxy for them
// this is needed to keep remark42 running behind of HTTPS serve all images via https
type Image struct {
RemarkURL string
RoutePath string
Enabled bool
Timeout time.Duration
RemarkURL string
RoutePath string
HTTP2HTTPS bool
CacheExternal bool
Timeout time.Duration
ImageService *image.Service
}
// Convert all img src links without https to proxied links
// Convert img src links to proxied links depends on enabled options
func (p Image) Convert(commentHTML string) string {
if !p.Enabled || strings.HasPrefix(p.RemarkURL, "http://") {
return commentHTML
if p.CacheExternal {
imgs, err := p.extract(commentHTML, func(img string) bool { return !strings.HasPrefix(img, p.RemarkURL) })
if err != nil {
return commentHTML
}
commentHTML = p.replace(commentHTML, imgs)
}
imgs, err := p.extract(commentHTML)
if p.HTTP2HTTPS && !strings.HasPrefix(p.RemarkURL, "http://") {
imgs, err := p.extract(commentHTML, func(img string) bool { return strings.HasPrefix(img, "http://") })
if err != nil {
return commentHTML
}
commentHTML = p.replace(commentHTML, imgs)
}
return commentHTML
}
// extract gets all images matching predicate and return list of src
func (p Image) extract(commentHTML string, imgSrcPred func(string) bool) ([]string, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML))
if err != nil {
return commentHTML
return nil, errors.Wrap(err, "can't create document")
}
result := []string{}
doc.Find("img").Each(func(i int, s *goquery.Selection) {
if im, ok := s.Attr("src"); ok {
if imgSrcPred(im) {
result = append(result, im)
}
}
})
return result, nil
}
// replace img links in commentHTML with route to proxy, base64 encoded original link
func (p Image) replace(commentHTML string, imgs []string) string {
for _, img := range imgs {
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(img))
resImgURL := p.RemarkURL + p.RoutePath + "?src=" + encodedImgURL
commentHTML = strings.Replace(commentHTML, img, resImgURL, -1)
}
return p.replace(commentHTML, imgs)
return commentHTML
}
// Handler returns http handler respond to proxied request
func (p Image) Handler(w http.ResponseWriter, r *http.Request) {
if !p.Enabled {
http.Error(w, "proxy disabled", http.StatusNotImplemented)
return
}
src, err := base64.URLEncoding.DecodeString(r.URL.Query().Get("src"))
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't decode image url", rest.ErrDecode)
return
}
timeout := 60 * time.Second // default
if p.Timeout > 0 {
timeout = p.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
client := http.Client{Timeout: 30 * time.Second}
var resp *http.Response
err = repeater.NewDefault(5, time.Second).Do(ctx, func() error {
var e error
req, e := http.NewRequest("GET", string(src), nil)
if e != nil {
return errors.Wrapf(e, "failed to make request for %s", r.URL.Query().Get("src"))
}
resp, e = client.Do(req.WithContext(ctx))
return e
})
imgURL := string(src)
var img []byte
imgID, err := image.CachedImgID(imgURL)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get image "+string(src), rest.ErrAssetNotFound)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse image url "+imgURL, rest.ErrAssetNotFound)
return
}
defer func() {
if e := resp.Body.Close(); e != nil {
log.Printf("[WARN] can't close body, %s", e)
// try to load from cache for case it was saved when CacheExternal was enabled
img, _ = p.ImageService.Load(imgID)
if img == nil {
img, err = p.downloadImage(context.Background(), imgURL)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusNotFound, err, "can't get image "+imgURL, rest.ErrAssetNotFound)
return
}
}()
if resp.StatusCode != http.StatusOK {
w.WriteHeader(resp.StatusCode)
return
}
for k, v := range resp.Header {
if strings.EqualFold(k, "Content-Type") {
w.Header().Set(k, v[0])
}
if strings.EqualFold(k, "Content-Length") {
w.Header().Set(k, v[0])
if p.CacheExternal {
p.cacheImage(bytes.NewReader(img), imgID)
}
}
// enforce client-side caching
etag := `"` + r.URL.Query().Get("src") + `"`
w.Header().Set("Etag", etag)
@@ -105,36 +117,56 @@ func (p Image) Handler(w http.ResponseWriter, r *http.Request) {
return
}
}
if _, e := io.Copy(w, resp.Body); e != nil {
log.Printf("[WARN] can't copy image stream, %s", e)
}
}
// extract gets all non-https images and return list of src
func (p Image) extract(commentHTML string) ([]string, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML))
w.Header().Add("Content-Type", p.ImageService.ImgContentType(img))
_, err = io.Copy(w, bytes.NewReader(img))
if err != nil {
return nil, errors.Wrap(err, "can't create document")
log.Printf("[WARN] can't copy image stream, %s", err)
}
result := []string{}
doc.Find("img").Each(func(i int, s *goquery.Selection) {
if im, ok := s.Attr("src"); ok {
if strings.HasPrefix(im, "http://") {
result = append(result, im)
}
}
// cache image from provided Reader using given ID
func (p Image) cacheImage(r io.Reader, imgID string) {
err := p.ImageService.SaveWithID(imgID, r)
if err != nil {
log.Printf("[WARN] unable to save image to the storage: %+v", err)
}
}
// download an image.
func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error) {
log.Printf("[DEBUG] downloading image %s", imgURL)
timeout := 60 * time.Second // default
if p.Timeout > 0 {
timeout = p.Timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client := http.Client{Timeout: 30 * time.Second}
var resp *http.Response
err := repeater.NewDefault(5, time.Second).Do(ctx, func() error {
var e error
req, e := http.NewRequest("GET", imgURL, nil)
if e != nil {
return errors.Wrapf(e, "failed to make request for %s", imgURL)
}
resp, e = client.Do(req.WithContext(ctx))
return e
})
return result, nil
}
// replace img links in commentHTML with route to proxy, base64 encoded original link
func (p Image) replace(commentHTML string, imgs []string) string {
for _, img := range imgs {
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(img))
resImgURL := p.RemarkURL + p.RoutePath + "?src=" + encodedImgURL
commentHTML = strings.Replace(commentHTML, img, resImgURL, -1)
if err != nil {
return nil, errors.Wrapf(err, "can't download image %s", imgURL)
}
return commentHTML
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("got unsuccessful response status %d while fetching %s", resp.StatusCode, imgURL)
}
imgData, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, errors.Errorf("unable to read image body")
}
return imgData, nil
}
+193 -29
View File
@@ -3,6 +3,7 @@ package proxy
import (
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
@@ -12,11 +13,40 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store/image"
)
func TestPicture_Extract(t *testing.T) {
// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
"JkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk" +
"8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAA" +
"ILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36I" +
"MRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2" +
"MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZm" +
"rSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhkt" +
"rRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0" +
"dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEj" +
"yy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67" +
"LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR" +
"11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5fe" +
"A+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fU" +
"yLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy" +
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNGBytes() []byte {
img, _ := ioutil.ReadAll(gopherPNG())
return img
}
func TestImage_Extract(t *testing.T) {
tbl := []struct {
inp string
res []string
@@ -46,94 +76,228 @@ func TestPicture_Extract(t *testing.T) {
[]string{},
},
}
img := Image{Enabled: true}
img := Image{HTTP2HTTPS: true}
for i, tt := range tbl {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
res, err := img.extract(tt.inp)
assert.Nil(t, err)
res, err := img.extract(tt.inp, func(src string) bool { return strings.HasPrefix(src, "http://") })
assert.NoError(t, err)
assert.Equal(t, tt.res, res)
})
}
}
func TestPicture_Replace(t *testing.T) {
img := Image{Enabled: true, RoutePath: "/img"}
func TestImage_Replace(t *testing.T) {
img := Image{HTTP2HTTPS: true, RoutePath: "/img"}
r := img.replace(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`,
[]string{"http://radio-t.com/img3.png", "http://images.pexels.com/67636/img4.jpeg"})
assert.Equal(t, `<img src="/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
}
func TestImage_Routes(t *testing.T) {
img := Image{Enabled: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy"}
imageStore := image.MockStore{}
img := Image{
HTTP2HTTPS: true,
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPServer(t)
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
// no image supposed to be cached
imageStore.On("Load", mock.Anything).Times(2).Return(nil, nil)
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
t.Logf("%+v", resp.Header)
assert.Equal(t, "123", resp.Header["Content-Length"][0])
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "1462", resp.Header["Content-Length"][0])
assert.Equal(t, "image/png", resp.Header["Content-Type"][0])
encodedImgURL = base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/no-such-image.png"))
resp, err = http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
assert.Equal(t, 404, resp.StatusCode)
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
encodedImgURL = base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "bad encoding"))
resp, err = http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
assert.Equal(t, 400, resp.StatusCode)
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestImage_RoutesTimedOut(t *testing.T) {
img := Image{Enabled: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", Timeout: 50 * time.Millisecond}
func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) {
imageStore := image.MockStore{}
img := Image{
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPServer(t)
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
imageStore.On("Load", mock.Anything).Once().Return(nil, nil)
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "1462", resp.Header["Content-Length"][0])
assert.Equal(t, "image/png", resp.Header["Content-Type"][0])
imageStore.AssertCalled(t, "Load", mock.Anything)
}
func TestImage_RoutesCachingImage(t *testing.T) {
imageStore := image.MockStore{}
img := Image{
CacheExternal: true,
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1500}),
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
imgURL := httpSrv.URL + "/image/img1.png"
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(imgURL))
imageStore.On("Load", mock.Anything).Once().Return(nil, nil)
imageStore.On("Save", mock.Anything, mock.Anything).Once().Return(nil)
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "1462", resp.Header["Content-Length"][0])
assert.Equal(t, "image/png", resp.Header["Content-Type"][0])
imageStore.AssertCalled(t, "Load", mock.Anything)
imageStore.AssertCalled(t, "Save", "cached_images/4b84b15bff6ee5796152495a230e45e3d7e947d9-"+image.Sha1Str(imgURL), gopherPNGBytes())
}
func TestImage_RoutesUsingCachedImage(t *testing.T) {
imageStore := image.MockStore{}
img := Image{
CacheExternal: true,
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
// In order to validate that cached data used cache "will return" some other data from what http server would
testImage := []byte(fmt.Sprintf("%256s", "X"))
imageStore.On("Load", mock.Anything).Once().Return(testImage, nil)
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "256", resp.Header["Content-Length"][0])
assert.Equal(t, "text/plain; charset=utf-8", resp.Header["Content-Type"][0],
"if you save text you receive text/plain in response, that's only fair option you got")
imageStore.AssertCalled(t, "Load", mock.Anything)
}
func TestImage_RoutesTimedOut(t *testing.T) {
imageStore := image.MockStore{}
img := Image{
HTTP2HTTPS: true,
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
Timeout: 50 * time.Millisecond,
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img-slow.png"))
// no image supposed to be cached
imageStore.On("Load", mock.Anything).Once().Return(nil, nil)
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
assert.Equal(t, 400, resp.StatusCode)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
require.Nil(t, err)
assert.NoError(t, resp.Body.Close())
require.NoError(t, err)
t.Log(string(b))
assert.True(t, strings.Contains(string(b), "deadline exceeded"))
}
func TestPicture_Convert(t *testing.T) {
img := Image{Enabled: true, RoutePath: "/img"}
func TestImage_ConvertProxyMode(t *testing.T) {
img := Image{HTTP2HTTPS: true, RoutePath: "/img"}
r := img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
r = img.Convert(`<img src="https://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://radio-t.com/img3.png"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
img = Image{Enabled: true, RoutePath: "/img", RemarkURL: "http://example.com"}
img = Image{HTTP2HTTPS: true, RoutePath: "/img", RemarkURL: "http://example.com"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz`)
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/> xyz`, r, "http:// remark url, no proxy")
img = Image{Enabled: false, RoutePath: "/img"}
img = Image{HTTP2HTTPS: false, RoutePath: "/img"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz`)
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/> xyz`, r, "disabled, no proxy")
}
func imgHTTPServer(t *testing.T) *httptest.Server {
func TestImage_ConvertCachingMode(t *testing.T) {
img := Image{CacheExternal: true, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r := img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="https://remark42.com/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
r = img.Convert(`<img src="https://radio-t.com/img3.png"/> xyz <img src="https://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cHM6Ly9yYWRpby10LmNvbS9pbWczLnBuZw=="/> xyz <img src="https://remark42.com/img?src=aHR0cHM6Ly9pbWFnZXMucGV4ZWxzLmNvbS82NzYzNi9pbWc0LmpwZWc=">`, r)
r = img.Convert(`<img src="https://remark42.com/pictures/1.png"/>`)
assert.Equal(t, `<img src="https://remark42.com/pictures/1.png"/>`, r)
img = Image{CacheExternal: false, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/>`)
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/>`, r)
// both Caching and Proxy enabled
img = Image{CacheExternal: true, HTTP2HTTPS: true, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="https://remark42.com/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
}
func imgHTTPTestsServer(t *testing.T) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/image/img1.png" {
t.Log("http img request", r.URL)
w.Header().Add("Content-Length", "123")
w.Header().Add("Content-Length", "1462")
w.Header().Add("Content-Type", "image/png")
_, err := w.Write([]byte(fmt.Sprintf("%123s", "X")))
_, err := w.Write(gopherPNGBytes())
assert.NoError(t, err)
return
}
+15 -5
View File
@@ -6,7 +6,7 @@ import (
"github.com/go-pkgz/auth/token"
"github.com/pkg/errors"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
// MustGetUserInfo fails if can't extract user data from the request.
@@ -35,17 +35,27 @@ func GetUserInfo(r *http.Request) (user store.User, err error) {
Admin: u.IsAdmin(),
Verified: u.BoolAttr("verified"),
Blocked: u.BoolAttr("blocked"),
SiteID: u.Audience,
}, nil
}
// GetUserOrEmpty attempts to get user info from request and returns empty object if failed
func GetUserOrEmpty(r *http.Request) store.User {
user, err := GetUserInfo(r)
if err != nil {
return store.User{}
}
return user
}
// SetUserInfo sets user into request context
func SetUserInfo(r *http.Request, user store.User) *http.Request {
u := token.User{
ID: user.ID,
Name: user.Name,
Picture: user.Picture,
IP: user.IP,
ID: user.ID,
Name: user.Name,
Picture: user.Picture,
IP: user.IP,
Audience: user.SiteID,
Attributes: map[string]interface{}{
"blocked": user.Blocked,
"verified": user.Verified,
+10 -9
View File
@@ -5,22 +5,23 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/backend/app/store"
"github.com/umputun/remark42/backend/app/store"
)
func TestUser_GetUserInfo(t *testing.T) {
r, err := http.NewRequest("GET", "http://blah.com", nil)
assert.Nil(t, err)
assert.NoError(t, err)
_, err = GetUserInfo(r)
assert.NotNil(t, err, "no user info")
assert.Error(t, err, "no user info")
r = SetUserInfo(r, store.User{Name: "test", ID: "id"})
r = SetUserInfo(r, store.User{Name: "test", ID: "id", SiteID: "test"})
u, err := GetUserInfo(r)
assert.Nil(t, err)
assert.Equal(t, store.User{Name: "test", ID: "id"}, u)
assert.NoError(t, err)
assert.Equal(t, store.User{Name: "test", ID: "id", SiteID: "test"}, u)
}
func TestUSer_MustGetUserInfo(t *testing.T) {
func TestUser_MustGetUserInfo(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Log("recovered from panic")
@@ -28,12 +29,12 @@ func TestUSer_MustGetUserInfo(t *testing.T) {
}()
r, err := http.NewRequest("GET", "http://blah.com", nil)
assert.Nil(t, err)
assert.NoError(t, err)
_ = MustGetUserInfo(r)
assert.Fail(t, "should panic")
r = SetUserInfo(r, store.User{Name: "test", ID: "id"})
u := MustGetUserInfo(r)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, store.User{Name: "test", ID: "id"}, u)
}
+51 -20
View File
@@ -3,36 +3,43 @@ package admin
import (
"errors"
"strings"
log "github.com/go-pkgz/lgr"
)
// Store defines interface returning admins info for given site
type Store interface {
Key() (key string, err error)
Admins(siteID string) (ids []string)
Email(siteID string) (email string)
Key(siteID string) (key string, err error)
Admins(siteID string) (ids []string, err error)
Email(siteID string) (email string, err error)
Enabled(siteID string) (ok bool, err error)
OnEvent(siteID string, et EventType) error
}
// StaticStore implements keys.Store with a single, predefined key
// EventType indicates type of the event
type EventType int
// enum of all event types
const (
EvCreate EventType = iota
EvDelete
EvUpdate
EvVote
)
// StaticStore implements keys.Store with a single set of admins and email for all sites
type StaticStore struct {
admins []string
email string
key string
}
// Key returns static key for all sites, allows empty site
func (s *StaticStore) Key() (key string, err error) {
if s.key == "" {
return "", errors.New("empty key for static key store")
}
return s.key, nil
sites []string
}
// NewStaticStore makes StaticStore instance with given key
func NewStaticStore(key string, admins []string, email string) *StaticStore {
log.Printf("[DEBUG] admin users %+v, email %s", admins, email)
return &StaticStore{key: key, admins: admins, email: email}
func NewStaticStore(key string, sites, adminIDs []string, email string) *StaticStore {
log.Printf("[DEBUG] admin users %+v, email %s", adminIDs, email)
return &StaticStore{key: key, sites: sites, admins: adminIDs, email: email}
}
// NewStaticKeyStore is a shortcut for making StaticStore for key consumers only
@@ -40,12 +47,36 @@ func NewStaticKeyStore(key string) *StaticStore {
return &StaticStore{key: key, admins: []string{}, email: ""}
}
// Admins returns static list of admin's ids, the same for all sites
func (s *StaticStore) Admins(string) (ids []string) {
return s.admins
// Key returns static key, same for all sites
func (s *StaticStore) Key(_ string) (key string, err error) {
if s.key == "" {
return "", errors.New("empty key for static key store")
}
return s.key, nil
}
// Admins returns static list of admin ids, the same for all sites
func (s *StaticStore) Admins(string) (ids []string, err error) {
return s.admins, nil
}
// Email gets static email address
func (s *StaticStore) Email(string) (email string) {
return s.email
func (s *StaticStore) Email(string) (email string, err error) {
return s.email, nil
}
// Enabled if always true for StaticStore
func (s *StaticStore) Enabled(site string) (ok bool, err error) {
if len(s.sites) == 0 {
return true, nil
}
for _, allowedSite := range s.sites {
if strings.EqualFold(allowedSite, site) {
return true, nil
}
}
return false, nil
}
// OnEvent doesn nothing for StaticStore
func (s *StaticStore) OnEvent(_ string, _ EventType) error { return nil }

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