283 Commits

Author SHA1 Message Date
Adhityaa Chandrasekar
a4c5cc8b9e commento.js: show comment 404 only hash is an ID
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 15:28:24 +05:30
Adhityaa Chandrasekar
fc83eed221 comment_new.go: simplify state logic
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 13:43:58 +05:30
Adhityaa Chandrasekar
18612933f6 commento.js: show error when comment link is 404
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 13:16:36 +05:30
Adhityaa Chandrasekar
aaa44a0bee api: log comment deleter and deletion date
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 12:46:43 +05:30
Drew Foehn
84bfd64e32 Dockerfile: Added build arg, RELEASE.
This allows you to build a debug version of commento and deploy to docker. Useful when debugging a containered version of commento.
2021-02-02 11:30:16 +05:30
Aaron
800902640b Update base images in Dockerfile 2021-02-02 11:26:57 +05:30
Souradip Mookerjee
5390c6f81c Fix twitter profile photo import bug 2021-01-16 00:26:17 +00:00
pawurb
326601394a commento.js: add lazy loading to avatar images 2020-05-22 16:21:49 -04:00
Wouter Groeneveld
3c3cf08656 utils.js: use global.origin in cookieSet() 2020-05-09 23:53:42 +00:00
Adhityaa Chandrasekar
e44ae1ce9d dashboard-main.scss: remove height from action buttons 2020-04-11 19:48:59 -04:00
Adhityaa Chandrasekar
025bb10c0b .gitlab-ci.yml: fix postgres to 9.6 2020-04-10 17:13:05 -04:00
Adhityaa Chandrasekar
9a4563fdb3 db: maintain 9.6 compatibility
I know it's generally frowned upon to make edits to existing migrations,
but this should be a transparent change that makes absolutely no
difference to existing users with the migration already applied.
However, PostgreSQL 9.6 users (still the default on Debian Stretch)
stand to gain a lot from this simple change.
2020-04-10 17:10:56 -04:00
Adhityaa Chandrasekar
daae592b5d commento.js: use a event listener in CSS overrides
Closes https://gitlab.com/commento/commento/-/issues/302
2020-03-31 07:00:46 -04:00
Adhityaa Chandrasekar
fbc98bce08 commento.js: remove stray console.log 2020-03-31 06:47:03 -04:00
Adhityaa Chandrasekar
36204ff81b constants.go: set version at compile time 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
f09a619d41 Dockerfile: use go 1.14 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
f8e6cc78dc .gitlab-ci.yml: use 1.14 in go test 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
3b2ed644a3 .gitlab-ci.yml: use 1.14 in go fmt 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
49a8669970 .gitlab-ci.yml: remove aws-upload-tags stage 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
7f97686b8f dashboard.html: use a generic commento import placeholder 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
af2626c443 domain_export_download.go: include json in filename 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
20027d0efe domain_export_download.go: do not add gzip encoding header
Adding a gzip encoding header would cause the browser to decode the data
on the client side. When re-importing the data, a plain JSON is imported
instead of a GZIP version.
2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
885b4c6689 api: use commentoExportV1 struct 2020-03-31 06:39:52 -04:00
igolaizola
0d929595cc api: import from commento export format
JSON data can be imported to restore previously exported data
or to migrate data from another self-hosted commento instance.

Closes https://gitlab.com/commento/commento/issues/239
2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
998bc43d8c settings.html: remove Stripe JavaScript
Closes https://gitlab.com/commento/commento/-/issues/272
2020-03-19 06:47:05 -04:00
Adhityaa Chandrasekar
881bd54c4f dashboard.html: include configuredOauths in warning logic 2020-03-19 06:47:05 -04:00
Adhityaa Chandrasekar
7f64614f60 commento.js: default to anonymous if only option
Closes https://gitlab.com/commento/commento/-/issues/285
2020-03-19 06:47:05 -04:00
atagulalan
d077241f09 Fixed Github OAuth name error 2020-03-19 06:47:05 -04:00
matclab
b57b6bcc12 oauth config: Use GITLAB_URL env variable
In order to use oauth from gitlab CE instance one has to be
able to provide the URL of the instance.

closes #209 (https://gitlab.com/commento/commento/issues/209)
2020-03-19 06:47:05 -04:00
Frieder Griesshammer
07cfcc9c17 dashboard.js: add defer to the script tag in installation guide on page
This matches the instructions with the documentation

[amended by @adtac]: Special thanks to @jeffreywyman for their work!
2020-03-19 06:45:42 -04:00
evalphobia
7f323b5abe oauth_twitter_callback.go: fix avatar icon and account url for Twitter 2020-03-19 05:04:29 -04:00
Yann Pringault
b14de2eb53 frontend: upgrade node-sass to support Node 13 2020-03-03 18:42:43 +01:00
Adhityaa Chandrasekar
55a3d1fd89 api: go fmt 2020-02-13 20:15:05 -05:00
Adhityaa Chandrasekar
986b05f89a api, frontend: restrict profile updates to commento provider 2020-02-13 20:11:47 -05:00
Adhityaa Chandrasekar
b7c214e910 commenter_update.go: parse empty links as undefined 2020-02-13 20:01:44 -05:00
Adhityaa Chandrasekar
ea3419e8b4 commenter_photo.go: resize images to 38px 2020-02-13 20:00:42 -05:00
Adhityaa Chandrasekar
b29147a95b commenter_update.go: make link optional 2020-02-13 19:38:47 -05:00
Adhityaa Chandrasekar
b3f2cf3064 database_connect.go: redact password before log message 2020-02-13 19:12:54 -05:00
Ricky Panzer
bbea9df8b8 commenter_login.go: include email in response
Fixes https://gitlab.com/commento/commento/issues/271
2020-02-13 18:47:58 -05:00
Ricky Panzer
44dd4fa00c commento.js: re-render comments after login
Re-render comments after login to accurately paint votes and allow
upvoting without reprompting user to login, even though they're logged
in.

Closes https://gitlab.com/commento/commento/issues/170
2020-02-13 18:47:58 -05:00
Mariusz Gronczewski
2006b02f59 smtp_configure.go: disable smtp auth when unset 2020-02-13 18:47:58 -05:00
PIE
c040f95e25 Dockerfile: add arm64 support 2020-02-13 18:47:58 -05:00
David Planella
90f39499a1 commento.js: define new thresholds for time ago descriptions 2020-02-13 18:33:18 +00:00
Kaspars
6978171885 fix errorf missing argument 2020-02-03 22:17:41 +02:00
Adhityaa Chandrasekar
166599a2c8 count.js: support data-page-id paths
Closes https://gitlab.com/commento/commento/issues/255
2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
15022ba3a0 count.js: replace with script-based data-custom-text 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
dc24a40a37 api, frontend: add account deletion
Closes https://gitlab.com/commento/commento/issues/120
2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
80dc91ca05 domain_delete.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
f6d6a1f77f owner_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
d6e7507b2c api: sql statements: replace spaces with tabs 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
e7a5e01379 comment_vote.go: clean up SQL 2020-01-02 14:19:44 -08:00
Dave
e0504a0c88 comment_vote.go: fix logger msg typo erorr->error
Closes https://gitlab.com/commento/commento/merge_requests/124
2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
6cfa9922de email_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
72a3f87c28 domain_get.go, domain_list.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
c94e5ca41f comment_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
a05d8eeb07 commenter_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
024859ff45 utils_sql.go: add sqlScanner interface 2020-01-02 14:19:44 -08:00
Dave
042fda0e8c commenter_get.go: single query commenterGetByCommenterToken
Closes https://gitlab.com/commento/commento/merge_requests/125
2020-01-02 14:19:41 -08:00
Adhityaa Chandrasekar
885c4dea9f dashboard.html: link to frontend configuration 2019-12-15 00:32:57 -08:00
Adhityaa Chandrasekar
0b1e2002d0 commento.js: add data-page-id option 2019-12-15 00:32:57 -08:00
Adhityaa Chandrasekar
2f9368a275 commenter_update.go: allow editing link, photo 2019-12-04 22:48:44 -08:00
Adhityaa Chandrasekar
2a11149034 .gitlab-ci.yml: remove dco 2019-12-04 22:12:31 -08:00
Adhityaa Chandrasekar
918a691ba3 api, frontend: allow editing profile information
Closes https://gitlab.com/commento/commento/issues/235
2019-12-04 22:12:29 -08:00
Adhityaa Chandrasekar
3e1576d494 api, frontend, db: add comment sorting
Closes https://gitlab.com/commento/commento/issues/215
2019-12-04 18:50:50 -08:00
Adhityaa Chandrasekar
3101af8a5c utils_sanitise.go: strip protocol before trailer
Fixes https://gitlab.com/commento/commento/issues/176
2019-11-21 01:15:08 -08:00
Adhityaa Chandrasekar
162b11bd7a api: do not batch email notifications
Closes https://gitlab.com/commento/commento/issues/234
2019-10-25 01:10:44 -07:00
Adhityaa Chandrasekar
0e5bcb8a79 commento.js: fix edit failure after new comment
Since the comment's data is not in commentsMap, the edit dialog does not
present the user with any text to edit.
2019-10-06 17:00:39 -07:00
Adhityaa Chandrasekar
b2b8d1b5d0 commento.js: hide deleted if children are deleted
This also adds a data-hide-deleted data tag that allows you to hide
deleted comments even if they have undeleted children.
2019-10-06 16:55:09 -07:00
Adhityaa Chandrasekar
c2bda4abc6 commento.js: confirm before deleting 2019-09-13 18:17:40 -07:00
Adhityaa Chandrasekar
ee7875cc1e commento.js: don't recursively delete comments 2019-09-13 18:13:38 -07:00
Adhityaa Chandrasekar
3ef4a79547 commento.js: use value instead of innerText for textarea 2019-09-13 17:26:53 -07:00
Adhityaa Chandrasekar
a9a1dc6376 frontend: migrate to gulp 4 2019-09-13 17:14:43 -07:00
Adhityaa Chandrasekar
b682fd14fa commenter_update.go: run go fmt 2019-09-13 16:15:00 -07:00
Adhityaa Chandrasekar
b278522e35 commento.js: preserve whitespace in edits 2019-09-13 16:15:00 -07:00
Adhityaa Chandrasekar
982a574512 email_notification_new.go: do not return on pageTitleUpdate errors 2019-09-13 16:15:00 -07:00
Adhityaa Chandrasekar
52f8df5183 config.go: parse config file before default
Closes https://gitlab.com/commento/commento/issues/187
2019-09-13 16:14:59 -07:00
Adhityaa Chandrasekar
9538c9036e api: update commenter information on new login 2019-08-21 21:05:17 -07:00
Adhityaa Chandrasekar
696361df4a reset.js: remove self.close call 2019-07-25 18:25:08 -07:00
Enrico Testori
4a8e90bd43 db: decrease comment count when a comment is deleted
Update commentsDeleteTriggerFunction in order to decrease the
counter before deleting nested comments.

Closes https://gitlab.com/commento/commento/issues/157
2019-07-25 18:25:08 -07:00
Adhityaa Chandrasekar
62340eb9c6 comment_new.go: create page before inserting comment
Fixes https://gitlab.com/commento/commento/issues/173
2019-07-25 18:25:08 -07:00
pawurb
ff04981cf5 count.js: allow customizing no comments text
Optional 'data-hide-no-comments-count' allows to hide comments count
when there are no comments.
2019-06-23 23:06:29 +02:00
Adhityaa Chandrasekar
f37e26bfc2 .gitlab-ci.yml: use go 1.12 2019-06-06 01:56:28 -07:00
Adhityaa Chandrasekar
73234832b6 api: vendor deps before tests 2019-06-06 01:44:17 -07:00
Adhityaa Chandrasekar
85456a019e api,frontend: add commenter password resets 2019-06-06 01:33:31 -07:00
Adhityaa Chandrasekar
36fea6e95b commento.js: make login box more intuitive 2019-06-05 22:15:12 -07:00
Adhityaa Chandrasekar
cc00387136 commento.js: preserve login button on failed oauth logins 2019-06-05 21:58:48 -07:00
Adhityaa Chandrasekar
cd8a2bbf99 Dockerfile: use minor point release containers 2019-06-05 21:43:13 -07:00
Adhityaa Chandrasekar
bdda465f33 api: use go mod 2019-06-05 21:43:12 -07:00
WGH
6e22d10b02 api/Makefile: remove -i flag to fix cross-compilation
When -i (install) flag is passed to `go build`, it attempts
to install dependencies of the target.

This will usually fail during cross-compilation due to GOROOT being
not writeable to non-root users:

    runtime/internal/sys
    go build runtime/internal/sys: mkdir /usr/lib/go/pkg/linux_arm: permission denied

Installing dependencies makes no sense in recent Go versions,
because compilation is cached anyway (see `go help cache`).
2019-06-05 20:45:03 -07:00
Adhityaa Chandrasekar
48bbceabc8 comment_get.go: run go fmt 2019-05-15 11:46:11 -07:00
Adhityaa Chandrasekar
cc1dfee017 api, frontend: add comment editing 2019-05-15 10:46:51 -07:00
Adhityaa Chandrasekar
ce19cb8842 comment_list.go: do not include markdown in most responses 2019-05-15 10:45:01 -07:00
Adhityaa Chandrasekar
b816c09802 commento-options.scss: optimise remove SVG slightly 2019-05-15 09:41:48 -07:00
Adhityaa Chandrasekar
8dbeecf71e commento.js: reset selfHex and state when logging out 2019-05-15 09:13:56 -07:00
Adhityaa Chandrasekar
5faa727ef8 api: add option to delete own comments 2019-05-15 09:13:39 -07:00
Adhityaa Chandrasekar
5228ff671a README.md: overhaul 2019-05-03 20:04:00 -04:00
Adhityaa Chandrasekar
038b6780d2 commento-common.scss: preserve newline in code blocks
Closes https://gitlab.com/commento/commento/issues/164
2019-05-03 19:50:27 -04:00
Adhityaa Chandrasekar
0a793b90cc common-main.scss: do not apply colours to input 2019-05-03 19:45:44 -04:00
Adhityaa Chandrasekar
ab312bfe20 commento.scss: have root-font take precedence 2019-05-03 19:38:23 -04:00
Adhityaa Chandrasekar
504d1bf866 release: v1.7.0 2019-05-01 20:39:49 -04:00
Adhityaa Chandrasekar
a4387b62ec common-main.scss: style all elements with Source Sans 2019-05-01 20:39:49 -04:00
Adhityaa Chandrasekar
1cce90dcf2 commento-login.scss: remove faux shadow from login box 2019-05-01 20:39:49 -04:00
Adhityaa Chandrasekar
8820dcd59e commento.js: remove debug console.log 2019-05-01 20:39:49 -04:00
Adhityaa Chandrasekar
638f7ba197 comment.go: omit state if empty 2019-05-01 20:39:49 -04:00
Adhityaa Chandrasekar
e1effd2a45 comment_list.go: check for moderator status conditionally 2019-05-01 20:39:49 -04:00
Adhityaa Chandrasekar
a9c48a8394 domain_new.go: reject domains with / 2019-05-01 20:39:48 -04:00
Adhityaa Chandrasekar
feeda79923 comment_list.go: refactor SQL statements 2019-05-01 18:45:14 -04:00
Adhityaa Chandrasekar
9d4ed4ca9f sigint.go: close DB connection before exit
Closes https://gitlab.com/commento/commento/issues/88
2019-05-01 18:39:40 -04:00
Adhityaa Chandrasekar
0b37b33530 smtp_configure.go: allow empty username/password
Closes https://gitlab.com/commento/commento/issues/126
2019-05-01 18:34:37 -04:00
Adhityaa Chandrasekar
b67d2ba58c commento.js: run nameWidthFix after elements are visible 2019-05-01 18:32:39 -04:00
Adhityaa Chandrasekar
5b6d31ce31 frontend: add option to not use Source Sans Pro
Closes https://gitlab.com/commento/commento/issues/136
2019-05-01 18:32:07 -04:00
Adhityaa Chandrasekar
409af7f205 autoserve: add option to serve prod 2019-05-01 18:17:57 -04:00
Adhityaa Chandrasekar
060520bd7f commento-common.scss: make buttons width unspecified 2019-05-01 18:17:49 -04:00
Adhityaa Chandrasekar
e396e043c6 source-sans.scss: use font-swapping 2019-05-01 17:52:02 -04:00
Adhityaa Chandrasekar
4a189fc698 frontend: add light font, remove external fonts
Partially closes https://gitlab.com/commento/commento/issues/136
2019-05-01 17:51:24 -04:00
Adhityaa Chandrasekar
cac1cfa84a main.go: add cron to auto cleanup SSO tokens 2019-04-20 23:39:09 -04:00
Adhityaa Chandrasekar
6317b384d9 sso: expire tokens after usage 2019-04-20 23:39:08 -04:00
Adhityaa Chandrasekar
fa2ccfe42e frontend: add markdown help 2019-04-20 22:04:56 -04:00
Adhityaa Chandrasekar
30772ec720 commento.js: do not set href if link is undefined
Fixes https://gitlab.com/commento/commento/issues/156
2019-04-20 21:10:08 -04:00
Adhityaa Chandrasekar
eec10491d6 dashboard-main.scss: remove theme css 2019-04-20 21:10:08 -04:00
Adhityaa Chandrasekar
e46f9cf9e7 version.go, footer.html: display version
Closes https://gitlab.com/commento/commento/issues/122
2019-04-20 21:10:08 -04:00
Adhityaa Chandrasekar
1d1cd46c2b frontend, api, db: add single sign-on
Closes https://gitlab.com/commento/commento/issues/90
2019-04-20 21:10:07 -04:00
Adhityaa Chandrasekar
536ec14b93 dashboard-domain.js: perform callback only if update successful 2019-04-20 20:33:50 -04:00
Adhityaa Chandrasekar
45c6361805 dashboard.html: move email schedule into general settings 2019-04-19 19:12:44 -04:00
Adhityaa Chandrasekar
a455ff54bc frontend, api: allow disabling login methods individually 2019-04-19 19:03:34 -04:00
Adhityaa Chandrasekar
0e54739980 commento.js: use a more stable sort in comments 2019-04-13 21:45:04 -04:00
Adhityaa Chandrasekar
7e9b3e5b26 commento.js: hide error div if no error reported 2019-04-13 21:34:27 -04:00
Adhityaa Chandrasekar
86393ad9ab commento.js: fix disappearing logout button
Fixes https://gitlab.com/commento/commento/issues/135
2019-04-13 21:29:06 -04:00
Adhityaa Chandrasekar
672863b48f commento.js: use question mark for anonymous comments 2019-04-13 21:18:28 -04:00
Adhityaa Chandrasekar
97f17d32ee commento.js: use black avatar for anonymous comments 2019-04-13 21:14:49 -04:00
Adhityaa Chandrasekar
be10baf971 commento.js: remove event listeners before adding new ones 2019-04-13 21:11:00 -04:00
Adhityaa Chandrasekar
9607c15c2b frontend, api: add ability to clear comments 2019-04-13 21:02:40 -04:00
Adhityaa Chandrasekar
65ea597c08 dashboard: use solid backgrounds and boxes 2019-04-13 20:50:53 -04:00
Adhityaa Chandrasekar
850dfc9712 dashboard-main.scss: remove subscription-nag CSS 2019-04-12 19:39:16 -04:00
MacOSO
3c9ba43ad1 commento-oauth.scss: fix alignment of social login buttons
In Microsoft Edge social login buttons wasn't be aligned.

Closes https://gitlab.com/commento/commento/issues/150
2019-04-10 05:15:45 +00:00
Johannes Zellner
b4790397c9 frontend: Make signup/login/passwordreset/forgot forms submittable
Fixes #117
2019-03-24 11:52:55 +01:00
Adhityaa Chandrasekar
9d6955b81e commento.scss: set 100% width for commento-root 2019-03-20 14:07:46 -04:00
Adhityaa Chandrasekar
5ffdf9988a commento.js: use commento namespace in wrapping 2019-03-19 01:23:40 -04:00
Adhityaa Chandrasekar
5f1d46c7b2 comment_count_test.go: use array of strings 2019-03-10 09:23:34 -04:00
Adhityaa Chandrasekar
a2c8a73d3e utils_html.go: return empty string if title is empty 2019-03-10 09:23:24 -04:00
Adhityaa Chandrasekar
4945e53553 commento.js: move name width fix and load hash to post-render 2019-03-02 15:24:16 -05:00
Adhityaa Chandrasekar
88d4f8afcf commento.js: use scrollIntoView for #commento 2019-03-02 15:17:00 -05:00
Adhityaa Chandrasekar
15b1640f89 count.js: add comment count display JS 2019-03-02 15:14:42 -05:00
Adhityaa Chandrasekar
216016a4be commento-card.scss: add line-height to name badges 2019-03-02 14:35:55 -05:00
Adhityaa Chandrasekar
a7cd8066f8 commento.js: undo changes on failed vote 2019-03-02 14:30:23 -05:00
Adhityaa Chandrasekar
295318e6a6 commento.js: use full timestamp in timeago title 2019-03-02 14:30:12 -05:00
Adhityaa Chandrasekar
d26b6f6e9f commento.js: load CSS async 2019-03-02 14:09:29 -05:00
Adhityaa Chandrasekar
c8a2ece0d6 commento.js: remove logo SVG from footer 2019-03-02 14:09:11 -05:00
Adhityaa Chandrasekar
e9ba79974b commento.js: remove vote button onclick listeners before reset 2019-03-02 13:28:08 -05:00
Adhityaa Chandrasekar
beb54035cf .gitlab-ci.yml: use tar.gz in release binaries 2019-02-23 16:18:36 -05:00
Adhityaa Chandrasekar
1ccc95fae4 commento-input.scss: use pre-wrap for textarea 2019-02-23 12:08:38 -05:00
Adhityaa Chandrasekar
fa3fa39696 source-sans.scss: fix cyrillic fonts typo 2019-02-23 11:26:14 -05:00
Adhityaa Chandrasekar
b9bf9e360a oauth.go: re-arrange oauth providers 2019-02-23 11:23:41 -05:00
Adhityaa Chandrasekar
ecbb505c97 commento.scss: remove all unset 2019-02-23 11:23:41 -05:00
Adhityaa Chandrasekar
789a58bd7a api, frontend: add moderator tag to mods in comments 2019-02-22 22:57:35 -05:00
Adhityaa Chandrasekar
c30da607cb oauth_twitter_callback.go: add better error handling 2019-02-22 22:27:53 -05:00
Adhityaa Chandrasekar
be197f2b69 frontend: use pointer for names in card 2019-02-22 22:24:46 -05:00
Adhityaa Chandrasekar
d4b466b04f api: mirror user photos for better privacy 2019-02-22 22:23:27 -05:00
Adhityaa Chandrasekar
95093326e0 oauth_github_callback.go: add better error handling 2019-02-22 22:23:27 -05:00
Adhityaa Chandrasekar
3e5c1c2656 oauth: add gitlab 2019-02-22 22:23:27 -05:00
Adhityaa Chandrasekar
c07f3e8b9f oauth: add twitter 2019-02-22 22:23:26 -05:00
Adhityaa Chandrasekar
d367ac8391 email-notification.txt: remove background on buttons 2019-02-22 18:31:28 -05:00
Adhityaa Chandrasekar
0609ef0e27 commento-mod-toolss.scss: inline display mod buttons 2019-02-22 18:24:39 -05:00
Adhityaa Chandrasekar
adb87d7029 commento.js: use commento namespace for allShow 2019-02-22 18:22:49 -05:00
Adhityaa Chandrasekar
23bec48ebb commento.js: use commenterToken from args
Fixes https://gitlab.com/commento/commento/issues/114
2019-02-22 18:20:14 -05:00
Adhityaa Chandrasekar
685f3a3a58 router_static.go: add charset for html content 2019-02-20 11:07:29 -05:00
Adhityaa Chandrasekar
f4489c9921 commento.js: compute mobileView only once 2019-02-20 10:55:51 -05:00
Adhityaa Chandrasekar
352c93bf88 commento-oauth.scss: explicitly define button width 2019-02-20 10:50:52 -05:00
Adhityaa Chandrasekar
27caa60e0c commento.scss: unset previously defined CSS 2019-02-20 10:44:53 -05:00
Adhityaa Chandrasekar
e0f188909f release: v1.6.2 2019-02-19 00:12:28 -05:00
Adhityaa Chandrasekar
0b78e9e70c owner_reset_password.go: use ownerHex in SELECT 2019-02-19 00:11:26 -05:00
Adhityaa Chandrasekar
ca797cd165 release: v1.6.1 2019-02-18 18:36:17 -05:00
Adhityaa Chandrasekar
15d729c6ac docker-compose.yml: remove db ports exposure 2019-02-18 18:35:28 -05:00
Adhityaa Chandrasekar
af1d1dcd0c Dockerfile: copy fonts file 2019-02-18 18:28:06 -05:00
Adhityaa Chandrasekar
b21c630208 release: v1.6.0 2019-02-18 17:40:22 -05:00
Adhityaa Chandrasekar
ef68dadcd7 email_moderate.go: include email in error message 2019-02-18 17:40:22 -05:00
Adhityaa Chandrasekar
8a7348ed6a email.go: run go fmt 2019-02-18 17:38:58 -05:00
Adhityaa Chandrasekar
5df5b5f112 fonts: add source sans fonts 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
c9677385f8 commenter_self.go: include email details 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
220109a157 commento.js: close login box when logged in 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
2e2d022c9b commento.js: add login button 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
63c4da0b8d api: add email moderation 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
e1c94ecf15 api,frontend: add unsubscribe 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
60a9f2cc15 button.scss: move .button to separate file 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
06f0f6f014 everywhere: add email notifications 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
69aba94590 commento.scss: move highlighted to commento-card.scss 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
52ce1e2660 comment_new.go: enforce RequireIdentification when anonymous
Yikes, I can't believe I forgot about this.
2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
7fc3910009 comment_new.go: use RequireModeration in anonymous clause 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
619231e32f commento.js: add scroll into view based on hash 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
a22b49a112 commenter_get.go: add TODO comment 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
b77089388f commento.js: show oauth above email login 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
b35155b9e5 commento.js: default to login instead of signup 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
5bb51bb131 commento-mod-tools.scss: use 12px font 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
8a8e0b53fc commento.scss: move avatar and dark-card to commento-card.scss 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
2b00384219 commento.scss: move mod-tools to separate file 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
bd695c53fd commento-input.scss: darken textarea placeholder 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
24ddf0657b commento-input.scss: remove unused approve and delete classes 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
e70546fb56 commento-oauth.scss: move oauth button styling to file 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
4ceb85ae51 commento.js, commento-input.scss: make textarea cleaner 2019-02-18 17:34:43 -05:00
Adhityaa Chandrasekar
41b0c8e5ca checkbox.scss: add commento- prefix 2019-02-18 17:33:44 -05:00
Adhityaa Chandrasekar
0acdd67e39 commenter_login.go: include commenter struct in response 2019-02-18 17:33:44 -05:00
Adhityaa Chandrasekar
caca7b8c41 commento-input.scss: use Source Sans Pro 2019-02-18 17:33:44 -05:00
Adhityaa Chandrasekar
24de2dbcb3 checkbox.scss: move checkbox element to separate file 2019-02-18 17:33:44 -05:00
Adhityaa Chandrasekar
9a14801990 commento.scss: rename buttons, tags, card file 2019-02-18 17:33:44 -05:00
Adhityaa Chandrasekar
de98ed81cd commento-logo.scss: rename to commento-footer.scss 2019-02-18 17:33:44 -05:00
Adhityaa Chandrasekar
3f7b65dee9 commento.js: fix anonymous selfHex logic in commentNew 2019-02-18 17:33:44 -05:00
Adhityaa Chandrasekar
8c09aa0ff6 commento.scss: remove unnecessary common-main include 2019-02-18 17:33:44 -05:00
dasZGFz
ce47f80e8e commento.js: Add iframe support 2019-02-12 23:35:33 -08:00
Adhityaa Chandrasekar
e434f59f9a release: v1.5.0 2019-02-04 18:11:03 -05:00
Adhityaa Chandrasekar
a4fbf67d73 api: run go fmt 2019-02-04 18:11:03 -05:00
Adhityaa Chandrasekar
1aea90cb07 dashboard.html: hide email settings for now 2019-02-04 18:08:17 -05:00
Adhityaa Chandrasekar
20b6660fa9 domain_import_disqus.go: update disqus export spec 2019-02-04 18:05:51 -05:00
Adhityaa Chandrasekar
815628c5ee dashboard-statistics.js: namespace numberify with global 2019-01-31 02:25:05 -05:00
Adhityaa Chandrasekar
6caa3e312c cron_domain_export_cleanup.go: change cron period 2019-01-31 02:19:47 -05:00
Adhityaa Chandrasekar
94829d9b83 api: add cron job to clean up views table 2019-01-31 02:19:12 -05:00
Adhityaa Chandrasekar
7be22b091f domain_export.go: raise error if SMTP is not configured 2019-01-31 02:08:13 -05:00
Adhityaa Chandrasekar
fff5e5c0e1 everywhere: add option to export data 2019-01-31 02:06:11 -05:00
Adhityaa Chandrasekar
f1ece27c99 dashboard: overhaul 2019-01-30 23:49:16 -05:00
Adhityaa Chandrasekar
5e48da6940 dashboard: improve moderator list explanation text 2019-01-30 22:49:25 -05:00
Adhityaa Chandrasekar
28fe1aaa89 dashboard.html: improve readability in panes 2019-01-30 22:44:36 -05:00
Adhityaa Chandrasekar
f846935a2a comment_new.go, commento.js: don't refresh when creating comments 2019-01-30 22:19:16 -05:00
Adhityaa Chandrasekar
42b452b9f8 commento-input.scss: biggen oauth button text size 2019-01-30 21:26:14 -05:00
Adhityaa Chandrasekar
514535a607 oauth_google_callback.go: fix potential nil panic 2019-01-30 21:25:09 -05:00
Adhityaa Chandrasekar
55f24b2de2 api: add github oauth
Closes https://gitlab.com/commento/commento/issues/20
2019-01-30 21:22:46 -05:00
Adhityaa Chandrasekar
24d76c2fb6 frontend: add favicon to all html files 2019-01-30 20:13:08 -05:00
Max
f2ff2b4940 Update docker.md with correct postgresql directory for persitence 2019-01-27 16:16:24 +00:00
Adhityaa Chandrasekar
6d1563e22a email-main.scss: unset width 2019-01-24 07:01:16 -05:00
Adhityaa Chandrasekar
9a3c181442 commento-input.scss: use pixels instead of rem 2019-01-24 06:56:30 -05:00
Adhityaa Chandrasekar
010b7336cd signup.js: fix document.location typo
I have no idea what that was.
2019-01-24 06:12:44 -05:00
Adhityaa Chandrasekar
00c197e2ee owner_new.go: perform email check before processing 2019-01-24 06:11:37 -05:00
Adhityaa Chandrasekar
c6a98d93e4 dashboard.html: update allow-anonymous subtext 2019-01-23 18:39:12 -05:00
Adhityaa Chandrasekar
edd8aae7a7 auth-common.js, reset.js: use global namespace for paramGet 2019-01-23 17:48:39 -05:00
Adhityaa Chandrasekar
3677d43aab templates: use plaintext instead of fancy HTML 2019-01-23 02:52:13 -05:00
Adhityaa Chandrasekar
0cdba65e48 release: v1.4.2 2019-01-23 00:27:55 -05:00
Adhityaa Chandrasekar
022fc06257 everywhere: improve anonymous comments logic 2019-01-23 00:26:25 -05:00
Adhityaa Chandrasekar
61bc73e705 dashboard.html: rephrase requireIdentification text on-screen 2019-01-22 23:55:18 -05:00
Adhityaa Chandrasekar
e0cf9a89f9 release: v1.4.1 2019-01-16 23:03:32 -05:00
Adhityaa Chandrasekar
bc92df8083 commento.js: use single arg in onclick
Fixes https://gitlab.com/commento/commento/issues/96
2019-01-16 23:03:32 -05:00
Adhityaa Chandrasekar
71947bbe2c README.md: use h3 for sections 2019-01-14 11:44:50 -05:00
Adhityaa Chandrasekar
5a029e2786 README.md: update with FAQ 2019-01-14 11:26:08 -05:00
Adhityaa Chandrasekar
7f9a39c330 .gitlab-ci.yml: specify AWS variables in job 2018-12-29 01:59:54 -05:00
Adhityaa Chandrasekar
633ccf427c .gitlab-ci.yml: environment: aws-upload-tags 2018-12-29 01:37:27 -05:00
Adhityaa Chandrasekar
51e4608c19 .gitlab-ci.yml: use environment for aws-upload-tags 2018-12-29 01:14:21 -05:00
Adhityaa Chandrasekar
612e620ffc .gitlab-ci.yml: remove ce suffix from CI scripts 2018-12-29 00:57:25 -05:00
Adhityaa Chandrasekar
642076a231 .gitlab-ci.yml: add automated aws push on release 2018-12-29 00:39:25 -05:00
Adhityaa Chandrasekar
f63639782c check-dco: add execute permission bit 2018-12-29 00:14:55 -05:00
Adhityaa Chandrasekar
02615088ff everywhere: remove -ce suffix 2018-12-28 12:58:01 -05:00
Adhityaa Chandrasekar
5aa3bc86eb release: v1.4.0 2018-12-28 12:18:54 -05:00
Adhityaa Chandrasekar
d5769d56c1 version.go: remove unused import 2018-12-28 12:18:54 -05:00
Adhityaa Chandrasekar
8eb0bc147c router_static.go: go fmt 2018-12-28 12:14:15 -05:00
Adhityaa Chandrasekar
96589a2658 commento.js: warn user if no div with id=commento
Closes https://gitlab.com/commento/commento/issues/42
2018-12-28 11:59:02 -05:00
Adhityaa Chandrasekar
34e39edcda version.go: remove ORIGIN and reduce frequency of logs 2018-12-28 11:47:54 -05:00
Adhityaa Chandrasekar
6d00a8e3aa frontend,api: refactor static router 2018-12-24 21:49:53 -05:00
Adhityaa Chandrasekar
c29b3a7a25 auth-common.js: add semicolon after use strict 2018-12-24 21:49:31 -05:00
Adhityaa Chandrasekar
a99bf15332 dashboard-installation.js: remove unnecessary commento namespace 2018-12-24 21:47:23 -05:00
Adhityaa Chandrasekar
7074800ecc gulpfile.js: remove redundant 'js' subpath 2018-12-24 21:40:28 -05:00
Adhityaa Chandrasekar
80fb09d941 dashboard-installation.js: use namespaced cdn variable 2018-12-24 21:40:28 -05:00
Adhityaa Chandrasekar
afabc25037 frontend: do not uglify devel JS files 2018-12-20 05:26:03 -05:00
Anton Linevych
c9e7a3f40a dashboard-setting.js: simplified if condition 2018-12-20 04:20:34 -05:00
Anton Linevych
4d82106aff gitlab-ci.yml: Install yarn for new F/E building system 2018-12-20 04:19:36 -05:00
Adhityaa Chandrasekar
4c0e261a8e autoserve: make it easy to switch between devel and prod 2018-12-20 04:18:43 -05:00
Anton Linevych
9e3935b3b2 frontend: use gulp, eslint, code refactor
Apologies in advance for the insanely huge commit. This commit is
primarily based on Anton Linevych's amazing work found here [1]. While
he had gone through the pains of making small, atomic changes to each
file, I had put off reviewing the PR for a long time. By the time I
finally got around to it, the project had changed so much that it didn't
make sense to keep the commits the same way. So I've cherry-picked most
of his commits, with some changes here and there, and I've squashed them
into one commit.

[1] https://gitlab.com/linevych/commento-ce/tree/feature/frontend_building_improvements
2018-12-20 04:14:48 -05:00
Adhityaa Chandrasekar
e4f71fe402 frontend: display options at the bottom on mobile 2018-12-20 01:27:55 -05:00
Adhityaa Chandrasekar
06c71f4e65 frontend: use commento namespace, event handlers, UI 2018-12-20 00:50:00 -05:00
Adhityaa Chandrasekar
9fcf67d667 api,frontend: add Akismet spam flagging integration 2018-12-19 22:57:02 -05:00
Adhityaa Chandrasekar
d1318daaca commento.scss: use yellow instead of blue for moderation 2018-12-19 22:14:36 -05:00
Adhityaa Chandrasekar
87a0c577bb frontend: display anonymous button in textarea 2018-12-18 19:46:43 -05:00
Adhityaa Chandrasekar
bcc81e1ad8 frontend: redesign score and timeago subtitle 2018-12-18 19:10:12 -05:00
Adhityaa Chandrasekar
1f8f3b3a36 commento.js: update avatar colors to be more distinct 2018-12-18 19:02:01 -05:00
Adhityaa Chandrasekar
610b61831d frontend,api: open source comment sticky 2018-12-18 18:57:32 -05:00
Adhityaa Chandrasekar
cf0b394b05 release: v1.3.1 2018-10-18 02:17:52 -04:00
Adhityaa Chandrasekar
a36b11f07d api: use dep instead of go get 2018-10-18 02:17:51 -04:00
Adhityaa Chandrasekar
93c9ce0cad release: v1.3.0 2018-10-18 01:05:01 -04:00
Adhityaa Chandrasekar
af88db42b2 owner_get.go: fix incorrent owner session selection field 2018-10-18 01:03:12 -04:00
Adhityaa Chandrasekar
0c6ccdc0a1 domain_delete.go: delete entries from pages on domain delete 2018-10-08 02:34:06 -04:00
Adhityaa Chandrasekar
6d3f8171e5 release: v1.2.0 2018-10-07 23:14:55 -04:00
Adhityaa Chandrasekar
3f1c570e84 db: store version in config table 2018-10-07 23:13:26 -04:00
Adhityaa Chandrasekar
cd88ae264e commento-input.scss: make create account button width 150px 2018-10-07 01:33:39 -04:00
Adhityaa Chandrasekar
b2abcae319 README.md: add Mozilla and DO 2018-10-05 22:48:52 -04:00
Adhityaa Chandrasekar
41a5c675bf release: v1.1.3 2018-09-28 09:39:38 -04:00
Adhityaa Chandrasekar
ac9f896a22 commento.js: reverse sorting algorithm 2018-09-28 09:39:07 -04:00
Adhityaa Chandrasekar
36d57914b2 release: v1.1.2 2018-09-27 17:13:05 -04:00
Adhityaa Chandrasekar
800ba5dd0d .gitlab-ci.yml: install git before usage 2018-09-27 17:11:57 -04:00
234 changed files with 12503 additions and 2614 deletions

View File

@@ -1,5 +1,4 @@
stages: stages:
- check-dco
- go-fmt - go-fmt
- go-test - go-test
- build-src - build-src
@@ -7,33 +6,18 @@ stages:
- docker-registry-master - docker-registry-master
- docker-registry-tags - docker-registry-tags
check-dco:
stage: check-dco
image: debian:buster
except:
- master
- tags
script:
- apt update
- apt install -y curl git jq
- bash ./scripts/check-dco
build-src: build-src:
stage: build-src stage: build-src
image: debian:buster image: debian:buster
variables:
GOPATH: $CI_PROJECT_DIR
except: except:
- master - master
- tags - tags
before_script:
- bash $CI_PROJECT_DIR/scripts/gitlab-ci-build-prescript
script: script:
- apt update - export GOPATH=/go
- apt install -y curl gnupg git make golang - export PATH=$PATH:/go/bin
- curl -sL https://deb.nodesource.com/setup_10.x | bash - - cd /go/src/$CI_PROJECT_NAME
- apt update
- apt install -y nodejs
- npm install -g html-minifier@3.5.7 uglify-js@3.4.1 sass@1.5.1
- mkdir -p src/gitlab.com/commento && cd src/gitlab.com/commento && ln -s $CI_PROJECT_DIR && cd $CI_PROJECT_NAME
- make devel - make devel
- make prod - make prod
@@ -46,29 +30,33 @@ build-docker:
- master - master
- tags - tags
script: script:
- docker build -t commento-ce . - docker build -t commento .
go-test: go-test:
stage: go-test stage: go-test
image: golang:1.10.2 image: golang:1.14
services: services:
- postgres:latest - postgres:9.6
variables: variables:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: commento_test POSTGRES_DB: commento_test
COMMENTO_POSTGRES: postgres://postgres:postgres@postgres/commento_test?sslmode=disable COMMENTO_POSTGRES: postgres://postgres:postgres@postgres/commento_test?sslmode=disable
GOPATH: $CI_PROJECT_DIR
except: except:
- master - master
- tags - tags
before_script:
- mkdir -p /go/src /go/bin /go/pkg
- export GOPATH=/go
- export PATH=$PATH:/go/bin
- ln -s $CI_PROJECT_DIR /go/src/$CI_PROJECT_NAME
script: script:
- mkdir -p src/gitlab.com/commento && cd src/gitlab.com/commento && ln -s $CI_PROJECT_DIR && cd $CI_PROJECT_NAME - cd /go/src/$CI_PROJECT_NAME
- make test - make test
go-fmt: go-fmt:
stage: go-fmt stage: go-fmt
image: golang:1.10.2 image: golang:1.14
except: except:
- master - master
- tags - tags
@@ -82,13 +70,13 @@ docker-registry-master:
services: services:
- docker:dind - docker:dind
only: only:
- master@commento/commento-ce - master@commento/commento
before_script: before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com
script: script:
- docker pull registry.gitlab.com/commento/commento-ce:latest || true - docker pull registry.gitlab.com/commento/commento:latest || true
- docker build --cache-from registry.gitlab.com/commento/commento-ce:latest --tag registry.gitlab.com/commento/commento-ce:latest . - docker build --cache-from registry.gitlab.com/commento/commento:latest --tag registry.gitlab.com/commento/commento:latest .
- docker push registry.gitlab.com/commento/commento-ce:latest - docker push registry.gitlab.com/commento/commento:latest
docker-registry-tags: docker-registry-tags:
stage: docker-registry-tags stage: docker-registry-tags
@@ -100,5 +88,6 @@ docker-registry-tags:
before_script: before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com
script: script:
- docker build --tag registry.gitlab.com/commento/commento-ce:$(git describe --tags) . - apk add git
- docker push registry.gitlab.com/commento/commento-ce:$(git describe --tags) - docker build --tag registry.gitlab.com/commento/commento:$(git describe --tags) .
- docker push registry.gitlab.com/commento/commento:$(git describe --tags)

View File

@@ -1,64 +1,53 @@
# backend build (api server) # backend build (api server)
FROM golang:1.10.2-alpine AS api-build FROM golang:1.15-alpine AS api-build
RUN apk add --no-cache --update bash dep make git curl g++
COPY ./api /go/src/commento-ce/api ARG RELEASE=prod
WORKDIR /go/src/commento-ce/api COPY ./api /go/src/commento/api/
WORKDIR /go/src/commento/api
RUN apk update && apk add bash make git RUN make ${RELEASE} -j$(($(nproc) + 1))
RUN make prod -j$(($(nproc) + 1))
# frontend build (html, js, css, images) # frontend build (html, js, css, images)
FROM node:10.3.0-alpine AS frontend-build FROM node:12-alpine AS frontend-build
RUN apk add --no-cache --update bash make python2 g++
COPY ./frontend /commento-ce/frontend/ ARG RELEASE=prod
WORKDIR /commento-ce/frontend/ COPY ./frontend /commento/frontend
WORKDIR /commento/frontend/
RUN apk update && apk add bash make RUN make ${RELEASE} -j$(($(nproc) + 1))
RUN npm install -g html-minifier@3.5.7 uglify-js@3.4.1 sass@1.5.1
RUN make prod -j$(($(nproc) + 1))
# templates build # templates and db build
FROM alpine:3.7 AS templates-build FROM alpine:3.13 AS templates-db-build
RUN apk add --no-cache --update bash make
COPY ./templates /commento-ce/templates ARG RELEASE=prod
WORKDIR /commento-ce/templates COPY ./templates /commento/templates
WORKDIR /commento/templates
RUN make ${RELEASE} -j$(($(nproc) + 1))
RUN apk update && apk add bash make COPY ./db /commento/db
WORKDIR /commento/db
RUN make prod -j$(($(nproc) + 1)) RUN make ${RELEASE} -j$(($(nproc) + 1))
# db build
FROM alpine:3.7 AS db-build
COPY ./db /commento-ce/db
WORKDIR /commento-ce/db
RUN apk update && apk add bash make
RUN make prod -j$(($(nproc) + 1))
# final image # final image
FROM alpine:3.7 FROM alpine:3.13
RUN apk add --no-cache --update ca-certificates
COPY --from=api-build /go/src/commento-ce/api/build/prod/commento-ce /commento-ce/commento-ce ARG RELEASE=prod
COPY --from=frontend-build /commento-ce/frontend/build/prod/*.html /commento-ce/
COPY --from=frontend-build /commento-ce/frontend/build/prod/css/*.css /commento-ce/css/
COPY --from=frontend-build /commento-ce/frontend/build/prod/js/*.js /commento-ce/js/
COPY --from=frontend-build /commento-ce/frontend/build/prod/images/* /commento-ce/images/
COPY --from=templates-build /commento-ce/templates/build/prod/templates/ /commento-ce/templates/
COPY --from=db-build /commento-ce/db/build/prod/db/ /commento-ce/db/
RUN apk update && apk add ca-certificates --no-cache COPY --from=api-build /go/src/commento/api/build/${RELEASE}/commento /commento/commento
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/js /commento/js
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/css /commento/css
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/images /commento/images
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/fonts /commento/fonts
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/*.html /commento/
COPY --from=templates-db-build /commento/templates/build/${RELEASE}/templates /commento/templates/
COPY --from=templates-db-build /commento/db/build/${RELEASE}/db /commento/db/
EXPOSE 8080 EXPOSE 8080
WORKDIR /commento/
WORKDIR /commento-ce/
ENV COMMENTO_BIND_ADDRESS="0.0.0.0" ENV COMMENTO_BIND_ADDRESS="0.0.0.0"
ENTRYPOINT ["/commento-ce/commento-ce"] ENTRYPOINT ["/commento/commento"]

158
Gopkg.lock generated
View File

@@ -1,158 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
digest = "1:5c3894b2aa4d6bead0ceeea6831b305d62879c871780e7b76296ded1b004bc57"
name = "cloud.google.com/go"
packages = ["compute/metadata"]
pruneopts = "UT"
revision = "64a2037ec6be8a4b0c1d1f706ed35b428b989239"
version = "v0.26.0"
[[projects]]
digest = "1:15042ad3498153684d09f393bbaec6b216c8eec6d61f63dff711de7d64ed8861"
name = "github.com/golang/protobuf"
packages = ["proto"]
pruneopts = "UT"
revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
version = "v1.1.0"
[[projects]]
digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1"
name = "github.com/gorilla/context"
packages = ["."]
pruneopts = "UT"
revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42"
version = "v1.1.1"
[[projects]]
digest = "1:664d37ea261f0fc73dd17f4a1f5f46d01fbb0b0d75f6375af064824424109b7d"
name = "github.com/gorilla/handlers"
packages = ["."]
pruneopts = "UT"
revision = "7e0847f9db758cdebd26c149d0ae9d5d0b9c98ce"
version = "v1.4.0"
[[projects]]
digest = "1:e73f5b0152105f18bc131fba127d9949305c8693f8a762588a82a48f61756f5f"
name = "github.com/gorilla/mux"
packages = ["."]
pruneopts = "UT"
revision = "e3702bed27f0d39777b0b37b664b6280e8ef8fbf"
version = "v1.6.2"
[[projects]]
digest = "1:37ce7d7d80531b227023331002c0d42b4b4b291a96798c82a049d03a54ba79e4"
name = "github.com/lib/pq"
packages = [
".",
"oid",
]
pruneopts = "UT"
revision = "90697d60dd844d5ef6ff15135d0203f65d2f53b8"
[[projects]]
digest = "1:9fb8ccf24ca918be80e6129761cf232de0c142537f8d9eeb7a3a779a7f38fdd4"
name = "github.com/lunny/html2md"
packages = ["."]
pruneopts = "UT"
revision = "13aaeeae9fb293668db3ef1e145064684735f3ce"
[[projects]]
digest = "1:a1f5a38c6c82d8f1e8a7b9fb9ea8b125b17188cdfb38f2cd08055ff9b51f5ec4"
name = "github.com/microcosm-cc/bluemonday"
packages = ["."]
pruneopts = "UT"
revision = "dafebb5b6ff2861a0d69af64991e10866c19be85"
version = "v1.0.0"
[[projects]]
digest = "1:5b3b29ce0e569f62935d9541dff2e16cc09df981ebde48e82259076a73a3d0c7"
name = "github.com/op/go-logging"
packages = ["."]
pruneopts = "UT"
revision = "b2cb9fa56473e98db8caba80237377e83fe44db5"
version = "v1"
[[projects]]
digest = "1:8bc629776d035c003c7814d4369521afe67fdb8efc4b5f66540d29343b98cf23"
name = "github.com/russross/blackfriday"
packages = ["."]
pruneopts = "UT"
revision = "55d61fa8aa702f59229e6cff85793c22e580eaf5"
version = "v1.5.1"
[[projects]]
branch = "master"
digest = "1:1ecf2a49df33be51e757d0033d5d51d5f784f35f68e5a38f797b2d3f03357d71"
name = "golang.org/x/crypto"
packages = [
"bcrypt",
"blowfish",
]
pruneopts = "UT"
revision = "de0752318171da717af4ce24d0a2e8626afaeb11"
[[projects]]
branch = "master"
digest = "1:aa58645c149c9c3b62dc7ff51460602a88fc7b887633f2546fcdde27c91e6f03"
name = "golang.org/x/net"
packages = [
"context",
"context/ctxhttp",
"html",
"html/atom",
]
pruneopts = "UT"
revision = "c39426892332e1bb5ec0a434a079bf82f5d30c54"
[[projects]]
branch = "master"
digest = "1:bea0314c10bd362ab623af4880d853b5bad3b63d0ab9945c47e461b8d04203ed"
name = "golang.org/x/oauth2"
packages = [
".",
"google",
"internal",
"jws",
"jwt",
]
pruneopts = "UT"
revision = "3d292e4d0cdc3a0113e6d207bb137145ef1de42f"
[[projects]]
digest = "1:c8907869850adaa8bd7631887948d0684f3787d0912f1c01ab72581a6c34432e"
name = "google.golang.org/appengine"
packages = [
".",
"internal",
"internal/app_identity",
"internal/base",
"internal/datastore",
"internal/log",
"internal/modules",
"internal/remote_api",
"internal/urlfetch",
"urlfetch",
]
pruneopts = "UT"
revision = "b1f26356af11148e710935ed1ac8a7f5702c7612"
version = "v1.1.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = [
"github.com/gorilla/handlers",
"github.com/gorilla/mux",
"github.com/lib/pq",
"github.com/lunny/html2md",
"github.com/microcosm-cc/bluemonday",
"github.com/op/go-logging",
"github.com/russross/blackfriday",
"golang.org/x/crypto/bcrypt",
"golang.org/x/oauth2",
"golang.org/x/oauth2/google",
]
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -1,45 +0,0 @@
[[constraint]]
name = "github.com/gorilla/handlers"
version = "1.4.0"
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.6.2"
[[constraint]]
# unfortunately, lib/pq doesn't have semver-ed releases yet
# TODO: don't use revisions, use a proper version once this is solved:
# https://github.com/lib/pq/issues/637
name = "github.com/lib/pq"
revision = "90697d60dd844d5ef6ff15135d0203f65d2f53b8"
[[constraint]]
# html2md doesn't have semver-ed releases yet either
# TODO: use a version once this is solved:
# https://github.com/lunny/html2md/issues/8
name = "github.com/lunny/html2md"
revision = "13aaeeae9fb293668db3ef1e145064684735f3ce"
[[constraint]]
name = "github.com/microcosm-cc/bluemonday"
version = "1.0.0"
[[constraint]]
name = "github.com/op/go-logging"
version = "1.0.0"
[[constraint]]
name = "golang.org/x/crypto"
branch = "master"
[[constraint]]
name = "golang.org/x/oauth2"
branch = "master"
[[constraint]]
name = "github.com/russross/blackfriday"
version = "1.5.1"
[prune]
go-tests = true
unused-packages = true

View File

@@ -1,62 +1,21 @@
<p align="center"> ### Commento
<a href="https://commento.io"><img src="https://user-images.githubusercontent.com/7521600/33375172-14b21f68-d52f-11e7-9b30-477682bccf8f.png" width=300></a>
</p>
<p align="center"><b>A bloat-free and privacy-focused discussion platform.</b></p> ##### [Homepage](https://commento.io) &nbsp;&ndash;&nbsp; [Demo](https://demo.commento.io) &nbsp;&ndash;&nbsp; [Documentation](https://docs.commento.io) &nbsp;&ndash;&nbsp; [Contributing](https://docs.commento.io/contributing/) &nbsp;&ndash;&nbsp; [#commento on Freenode](http://webchat.freenode.net/?channels=%23commento)
Commento is a discussion platform that you can embed on your blog, news articles, and any place where you want your readers to add comments. Commento is fast, lightweight, and privacy-focused; we'll never sell your data, show ads, embed third-party tracking scripts, or inject affiliate links. Commento is a platform that you can embed in your website to allow your readers to add comments. It's reasonably fast lightweight. Supports markdown, import from Disqus, voting, automated spam detection, moderation tools, sticky comments, thread locking, OAuth login, single sign-on, and email notifications.
#### Features ###### How is this different from Disqus, Facebook Comments, and the rest?
- Privacy-focused Most other products in this space do not respect your privacy; showing ads is their primary business model and that nearly always comes at the users' cost. Commento has no ads; you're the customer, not the product. While Commento is [free software](https://www.gnu.org/philosophy/free-sw.en.html), in order to keep the service sustainable, the [hosted cloud version](https://commento.io) is not offered free of cost. Commento is also orders of magnitude lighter than alternatives.
- Super lightweight, allowing for fast pageloads
- Automatic spam filtering
- Review and approve or delete comments through the moderation interface
- Modern interface with a clean design
- OAuth support (Google login, for example)
- Custom CSS theming
- Import from existing services (like Disqus)
- Completely free and open source (MIT Expat license)
#### Editions ###### Why should I care about my readers' privacy?
There are three editions of Commento. For starters, your readers value their privacy. Not caring about them is disrespectful and you will end up alienating your audience; they won't come back. Disqus still isn't GDPR-compliant (according to their <a href="https://help.disqus.com/terms-and-policies/privacy-faq" title="At the time of writing (28 December 2018)" rel="nofollow">privacy policy</a>). Disqus adds megabytes to your page size; what happens when a random third-party script that is injected into your website turns malicious?
- **Commento Community Edition (CE)** is open source software that's freely available under the MIT license. #### Installation
- [**Commento Enterprise Edition (EE)**](https://commento.io/pricing#self-hosted) includes extra features geared towards organizations that want to self-host.
- [**Commento Hosted**](https://commento.io) is a hosted version of Commento for those who don't want to host and manage servers. This is currently in private beta and you can [add yourself to the waiting list here](https://commento.io).
#### Installation and Configuration Read the [documentation to get started](https://docs.commento.io/installation/).
See our [documentation on how to install Commento](http://docs.commento.io/installation.html) to get started. We offer several ways to install the software, including a Docker image.
Once you've installed the software, you need to configure it with various environment variables before starting the service. To learn more about this, refer to our documentation on [configuring Commento](https://docs.commento.io/configuration.html).
#### Contributing #### Contributing
Commento is possible only because of its community. If this is your first contribution to Commento, please go through the [development documentation](https://docs.commento.io/contributing.html) before you begin. If this is your first contribution to Commento, please go through the [contribution guidelines](https://docs.commento.io/contributing/) before you begin. If you have any questions, join [#commento on Freenode](http://webchat.freenode.net/?channels=%23commento).
Help will always be given to those who ask for it. We use IRC for chat to collaborate with other developers. You're invited to [hang out with us](https://irc.commento.io) in the `#commento-dev` channel on freenode if you want to contribute to Commento!
#### License
```
Copyright 2018 Commento, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

View File

@@ -7,9 +7,9 @@ PROD_BUILD_DIR = $(BUILD_DIR)/prod
GO_SRC_DIR = . GO_SRC_DIR = .
GO_SRC_FILES = $(wildcard $(GO_SRC_DIR)/*.go) GO_SRC_FILES = $(wildcard $(GO_SRC_DIR)/*.go)
GO_DEVEL_BUILD_DIR = $(DEVEL_BUILD_DIR) GO_DEVEL_BUILD_DIR = $(DEVEL_BUILD_DIR)
GO_DEVEL_BUILD_BINARY = $(GO_DEVEL_BUILD_DIR)/commento-ce GO_DEVEL_BUILD_BINARY = $(GO_DEVEL_BUILD_DIR)/commento
GO_PROD_BUILD_DIR = $(PROD_BUILD_DIR) GO_PROD_BUILD_DIR = $(PROD_BUILD_DIR)
GO_PROD_BUILD_BINARY = $(GO_PROD_BUILD_DIR)/commento-ce GO_PROD_BUILD_BINARY = $(GO_PROD_BUILD_DIR)/commento
devel: devel-go devel: devel-go
@@ -25,15 +25,15 @@ clean:
# later down the line). # later down the line).
devel-go: devel-go:
go get -v . GO111MODULE=on go mod vendor
go build -i -v -o $(GO_DEVEL_BUILD_BINARY) GO111MODULE=on go build -mod=vendor -v -o $(GO_DEVEL_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)"
prod-go: prod-go:
go get -v . GO111MODULE=on go mod vendor
go build -i -v -o $(GO_PROD_BUILD_BINARY) GO111MODULE=on go build -mod=vendor -v -o $(GO_PROD_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)"
test-go: test-go:
go get -v . GO111MODULE=on go mod vendor
go test -v . go test -v .
$(shell mkdir -p $(GO_DEVEL_BUILD_DIR) $(GO_PROD_BUILD_DIR)) $(shell mkdir -p $(GO_DEVEL_BUILD_DIR) $(GO_PROD_BUILD_DIR))

31
api/akismet.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"github.com/adtac/go-akismet/akismet"
"os"
)
func isSpam(domain string, userIp string, userAgent string, name string, email string, url string, markdown string) bool {
akismetKey := os.Getenv("AKISMET_KEY")
if akismetKey == "" {
return false
}
res, err := akismet.Check(&akismet.Comment{
Blog: domain,
UserIP: userIp,
UserAgent: userAgent,
CommentType: "comment",
CommentAuthor: name,
CommentAuthorEmail: email,
CommentAuthorURL: url,
CommentContent: markdown,
}, akismetKey)
if err != nil {
logger.Errorf("error: cannot validate commenet using Akismet: %v", err)
return true
}
return res
}

View File

@@ -13,7 +13,8 @@ type comment struct {
Html string `json:"html"` Html string `json:"html"`
ParentHex string `json:"parentHex"` ParentHex string `json:"parentHex"`
Score int `json:"score"` Score int `json:"score"`
State string `json:"state"` State string `json:"state,omitempty"`
CreationDate time.Time `json:"creationDate"` CreationDate time.Time `json:"creationDate"`
Direction int `json:"direction"` Direction int `json:"direction"`
Deleted bool `json:"deleted"`
} }

View File

@@ -11,7 +11,7 @@ func commentApprove(commentHex string) error {
statement := ` statement := `
UPDATE comments UPDATE comments
SET state = 'approved' SET state = 'approved'
WHERE commentHex = $1; WHERE commentHex = $1;
` `

View File

@@ -1,27 +1,51 @@
package main package main
import ( import (
"github.com/lib/pq"
"net/http" "net/http"
) )
func commentCount(domain string, path string) (int, error) { func commentCount(domain string, paths []string) (map[string]int, error) {
// path can be empty commentCounts := map[string]int{}
if domain == "" { if domain == "" {
return 0, errorMissingField return nil, errorMissingField
} }
p, err := pageGet(domain, path) if len(paths) == 0 {
return nil, errorEmptyPaths
}
statement := `
SELECT path, commentCount
FROM pages
WHERE domain = $1 AND path = ANY($2);
`
rows, err := db.Query(statement, domain, pq.Array(paths))
if err != nil { if err != nil {
return 0, errorInternal logger.Errorf("cannot get comments: %v", err)
return nil, errorInternal
}
defer rows.Close()
for rows.Next() {
var path string
var commentCount int
if err = rows.Scan(&path, &commentCount); err != nil {
logger.Errorf("cannot scan path and commentCount: %v", err)
return nil, errorInternal
}
commentCounts[path] = commentCount
} }
return p.CommentCount, nil return commentCounts, nil
} }
func commentCountHandler(w http.ResponseWriter, r *http.Request) { func commentCountHandler(w http.ResponseWriter, r *http.Request) {
type request struct { type request struct {
Domain *string `json:"domain"` Domain *string `json:"domain"`
Path *string `json:"path"` Paths *[]string `json:"paths"`
} }
var x request var x request
@@ -31,13 +55,12 @@ func commentCountHandler(w http.ResponseWriter, r *http.Request) {
} }
domain := domainStrip(*x.Domain) domain := domainStrip(*x.Domain)
path := *x.Path
count, err := commentCount(domain, path) commentCounts, err := commentCount(domain, *x.Paths)
if err != nil { if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()}) bodyMarshal(w, response{"success": false, "message": err.Error()})
return return
} }
bodyMarshal(w, response{"success": true, "count": count}) bodyMarshal(w, response{"success": true, "commentCounts": commentCounts})
} }

View File

@@ -14,14 +14,14 @@ func TestCommentCountBasics(t *testing.T) {
commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC()) commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC())
commentNew(commenterHex, "example.com", "/path.html", "root", "**baz**", "unapproved", time.Now().UTC()) commentNew(commenterHex, "example.com", "/path.html", "root", "**baz**", "unapproved", time.Now().UTC())
count, err := commentCount("example.com", "/path.html") counts, err := commentCount("example.com", []string{"/path.html"})
if err != nil { if err != nil {
t.Errorf("unexpected error counting comments: %v", err) t.Errorf("unexpected error counting comments: %v", err)
return return
} }
if count != 2 { if counts["/path.html"] != 3 {
t.Errorf("expected count=2 got count=%d", count) t.Errorf("expected count=3 got count=%d", counts["/path.html"])
return return
} }
} }
@@ -29,25 +29,25 @@ func TestCommentCountBasics(t *testing.T) {
func TestCommentCountNewPage(t *testing.T) { func TestCommentCountNewPage(t *testing.T) {
failTestOnError(t, setupTestEnv()) failTestOnError(t, setupTestEnv())
count, err := commentCount("example.com", "/path.html") counts, err := commentCount("example.com", []string{"/path.html"})
if err != nil { if err != nil {
t.Errorf("unexpected error counting comments: %v", err) t.Errorf("unexpected error counting comments: %v", err)
return return
} }
if count != 0 { if counts["/path.html"] != 0 {
t.Errorf("expected count=0 got count=%d", count) t.Errorf("expected count=0 got count=%d", counts["/path.html"])
return return
} }
} }
func TestCommentCountEmpty(t *testing.T) { func TestCommentCountEmpty(t *testing.T) {
if _, err := commentCount("example.com", ""); err != nil { if _, err := commentCount("example.com", []string{""}); err != nil {
t.Errorf("unexpected error counting comments on empty path: %v", err) t.Errorf("unexpected error counting comments on empty path: %v", err)
return return
} }
if _, err := commentCount("", ""); err == nil { if _, err := commentCount("", []string{""}); err == nil {
t.Errorf("expected error not found counting comments with empty everything") t.Errorf("expected error not found counting comments with empty everything")
return return
} }

View File

@@ -2,18 +2,26 @@ package main
import ( import (
"net/http" "net/http"
"time"
) )
func commentDelete(commentHex string) error { func commentDelete(commentHex string, deleterHex string) error {
if commentHex == "" { if commentHex == "" || deleterHex == "" {
return errorMissingField return errorMissingField
} }
statement := ` statement := `
DELETE FROM comments UPDATE comments
WHERE commentHex=$1; SET
deleted = true,
markdown = '[deleted]',
html = '[deleted]',
commenterHex = 'anonymous',
deleterHex = $2,
deletionDate = $3
WHERE commentHex = $1;
` `
_, err := db.Exec(statement, commentHex) _, err := db.Exec(statement, commentHex, deleterHex, time.Now().UTC())
if err != nil { if err != nil {
// TODO: make sure this is the error is actually non-existant commentHex // TODO: make sure this is the error is actually non-existant commentHex
@@ -41,6 +49,12 @@ func commentDeleteHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
cm, err := commentGetByCommentHex(*x.CommentHex)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
domain, _, err := commentDomainPathGet(*x.CommentHex) domain, _, err := commentDomainPathGet(*x.CommentHex)
if err != nil { if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()}) bodyMarshal(w, response{"success": false, "message": err.Error()})
@@ -53,12 +67,12 @@ func commentDeleteHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if !isModerator { if !isModerator && cm.CommenterHex != c.CommenterHex {
bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()}) bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()})
return return
} }
if err = commentDelete(*x.CommentHex); err != nil { if err = commentDelete(*x.CommentHex, c.CommenterHex); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()}) bodyMarshal(w, response{"success": false, "message": err.Error()})
return return
} }

View File

@@ -8,15 +8,16 @@ import (
func TestCommentDeleteBasics(t *testing.T) { func TestCommentDeleteBasics(t *testing.T) {
failTestOnError(t, setupTestEnv()) failTestOnError(t, setupTestEnv())
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC()) commenterHex := "temp-commenter-hex"
commentNew("temp-commenter-hex", "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC()) commentHex, _ := commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
commentNew(commenterHex, "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC())
if err := commentDelete(commentHex); err != nil { if err := commentDelete(commentHex, commenterHex); err != nil {
t.Errorf("unexpected error deleting comment: %v", err) t.Errorf("unexpected error deleting comment: %v", err)
return return
} }
c, _, _ := commentList("temp-commenter-hex", "example.com", "/path.html", false) c, _, _ := commentList(commenterHex, "example.com", "/path.html", false)
if len(c) != 0 { if len(c) != 0 {
t.Errorf("expected no comments found %d comments", len(c)) t.Errorf("expected no comments found %d comments", len(c))
@@ -27,7 +28,7 @@ func TestCommentDeleteBasics(t *testing.T) {
func TestCommentDeleteEmpty(t *testing.T) { func TestCommentDeleteEmpty(t *testing.T) {
failTestOnError(t, setupTestEnv()) failTestOnError(t, setupTestEnv())
if err := commentDelete(""); err == nil { if err := commentDelete("", "test-commenter-hex"); err == nil {
t.Errorf("expected error deleting comment with empty commentHex") t.Errorf("expected error deleting comment with empty commentHex")
return return
} }

View File

@@ -8,7 +8,7 @@ func commentDomainPathGet(commentHex string) (string, string, error) {
} }
statement := ` statement := `
SELECT domain, path SELECT domain, path
FROM comments FROM comments
WHERE commentHex = $1; WHERE commentHex = $1;
` `

66
api/comment_edit.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"net/http"
)
func commentEdit(commentHex string, markdown string) (string, error) {
if commentHex == "" {
return "", errorMissingField
}
html := markdownToHtml(markdown)
statement := `
UPDATE comments
SET markdown = $2, html = $3
WHERE commentHex=$1;
`
_, err := db.Exec(statement, commentHex, markdown, html)
if err != nil {
// TODO: make sure this is the error is actually non-existant commentHex
return "", errorNoSuchComment
}
return html, nil
}
func commentEditHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
CommenterToken *string `json:"commenterToken"`
CommentHex *string `json:"commentHex"`
Markdown *string `json:"markdown"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
cm, err := commentGetByCommentHex(*x.CommentHex)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if cm.CommenterHex != c.CommenterHex {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
html, err := commentEdit(*x.CommentHex, *x.Markdown)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "html": html})
}

50
api/comment_get.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import ()
var commentsRowColumns = `
comments.commentHex,
comments.commenterHex,
comments.markdown,
comments.html,
comments.parentHex,
comments.score,
comments.state,
comments.deleted,
comments.creationDate
`
func commentsRowScan(s sqlScanner, c *comment) error {
return s.Scan(
&c.CommentHex,
&c.CommenterHex,
&c.Markdown,
&c.Html,
&c.ParentHex,
&c.Score,
&c.State,
&c.Deleted,
&c.CreationDate,
)
}
func commentGetByCommentHex(commentHex string) (comment, error) {
if commentHex == "" {
return comment{}, errorMissingField
}
statement := `
SELECT ` + commentsRowColumns + `
FROM comments
WHERE comments.commentHex = $1;
`
row := db.QueryRow(statement, commentHex)
var c comment
if err := commentsRowScan(row, &c); err != nil {
// TODO: is this the only error?
return c, errorNoSuchComment
}
return c, nil
}

View File

@@ -12,22 +12,27 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
} }
statement := ` statement := `
SELECT commentHex, commenterHex, markdown, html, parentHex, score, state, creationDate SELECT
commentHex,
commenterHex,
markdown,
html,
parentHex,
score,
state,
deleted,
creationDate
FROM comments FROM comments
WHERE WHERE
comments.domain = $1 AND comments.domain = $1 AND
comments.path = $2 comments.path = $2
` `
if !includeUnapproved { if !includeUnapproved {
if commenterHex == "anonymous" { if commenterHex == "anonymous" {
statement += ` statement += `AND state = 'approved'`
AND state = 'approved'
`
} else { } else {
statement += ` statement += `AND (state = 'approved' OR commenterHex = $3)`
AND (state = 'approved' OR commenterHex = $3)
`
} }
} }
@@ -54,16 +59,25 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
comments := []comment{} comments := []comment{}
for rows.Next() { for rows.Next() {
c := comment{} c := comment{}
if err = rows.Scan(&c.CommentHex, &c.CommenterHex, &c.Markdown, &c.Html, &c.ParentHex, &c.Score, &c.State, &c.CreationDate); err != nil { if err = rows.Scan(
&c.CommentHex,
&c.CommenterHex,
&c.Markdown,
&c.Html,
&c.ParentHex,
&c.Score,
&c.State,
&c.Deleted,
&c.CreationDate); err != nil {
return nil, nil, errorInternal return nil, nil, errorInternal
} }
if commenterHex != "anonymous" { if commenterHex != "anonymous" {
statement = ` statement = `
SELECT direction SELECT direction
FROM votes FROM votes
WHERE commentHex=$1 AND commenterHex=$2; WHERE commentHex=$1 AND commenterHex=$2;
` `
row := db.QueryRow(statement, c.CommentHex, commenterHex) row := db.QueryRow(statement, c.CommentHex, commenterHex)
if err = row.Scan(&c.Direction); err != nil { if err = row.Scan(&c.Direction); err != nil {
@@ -72,6 +86,10 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
} }
} }
if commenterHex != c.CommenterHex {
c.Markdown = ""
}
if !includeUnapproved { if !includeUnapproved {
c.State = "" c.State = ""
} }
@@ -120,6 +138,8 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
commenterHex := "anonymous" commenterHex := "anonymous"
isModerator := false isModerator := false
modList := map[string]bool{}
if *x.CommenterToken != "anonymous" { if *x.CommenterToken != "anonymous" {
c, err := commenterGetByCommenterToken(*x.CommenterToken) c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil { if err != nil {
@@ -134,11 +154,15 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
} }
for _, mod := range d.Moderators { for _, mod := range d.Moderators {
modList[mod.Email] = true
if mod.Email == c.Email { if mod.Email == c.Email {
isModerator = true isModerator = true
break
} }
} }
} else {
for _, mod := range d.Moderators {
modList[mod.Email] = true
}
} }
domainViewRecord(domain, commenterHex) domainViewRecord(domain, commenterHex)
@@ -149,16 +173,33 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
_commenters := map[string]commenter{}
for commenterHex, cr := range commenters {
if _, ok := modList[cr.Email]; ok {
cr.IsModerator = true
}
cr.Email = ""
_commenters[commenterHex] = cr
}
bodyMarshal(w, response{ bodyMarshal(w, response{
"success": true, "success": true,
"domain": domain, "domain": domain,
"comments": comments, "comments": comments,
"commenters": commenters, "commenters": _commenters,
"requireModeration": d.RequireModeration, "requireModeration": d.RequireModeration,
"requireIdentification": d.RequireIdentification, "requireIdentification": d.RequireIdentification,
"isFrozen": d.State == "frozen", "isFrozen": d.State == "frozen",
"isModerator": isModerator, "isModerator": isModerator,
"defaultSortPolicy": d.DefaultSortPolicy,
"attributes": p, "attributes": p,
"configuredOauths": configuredOauths, "configuredOauths": map[string]bool{
"commento": d.CommentoProvider,
"google": googleConfigured && d.GoogleProvider,
"twitter": twitterConfigured && d.TwitterProvider,
"github": githubConfigured && d.GithubProvider,
"gitlab": gitlabConfigured && d.GitlabProvider,
"sso": d.SsoProvider,
},
}) })
} }

View File

@@ -30,6 +30,10 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin
html := markdownToHtml(markdown) html := markdownToHtml(markdown)
if err = pageNew(domain, path); err != nil {
return "", err
}
statement := ` statement := `
INSERT INTO INSERT INTO
comments (commentHex, domain, path, commenterHex, parentHex, markdown, html, creationDate, state) comments (commentHex, domain, path, commenterHex, parentHex, markdown, html, creationDate, state)
@@ -41,10 +45,6 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin
return "", errorInternal return "", errorInternal
} }
if err = pageNew(domain, path); err != nil {
return "", err
}
return commentHex, nil return commentHex, nil
} }
@@ -77,47 +77,41 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// logic: (empty column indicates the value doesn't matter) if d.RequireIdentification && *x.CommenterToken == "anonymous" {
// | anonymous | moderator | requireIdentification | requireModeration | approved? | bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
// |-----------+-----------+-----------------------+-------------------+-----------| return
// | yes | | | | no | }
// | no | yes | | | yes |
// | no | no | | yes | yes |
// | no | no | | no | no |
var commenterHex string
var state string
var commenterHex, commenterName, commenterEmail, commenterLink string
var isModerator bool
if *x.CommenterToken == "anonymous" { if *x.CommenterToken == "anonymous" {
state = "unapproved" commenterHex, commenterName, commenterEmail, commenterLink = "anonymous", "Anonymous", "", ""
commenterHex = "anonymous"
} else { } else {
c, err := commenterGetByCommenterToken(*x.CommenterToken) c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil { if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()}) bodyMarshal(w, response{"success": false, "message": err.Error()})
return return
} }
commenterHex, commenterName, commenterEmail, commenterLink = c.CommenterHex, c.Name, c.Email, c.Link
// cheaper than a SQL query as we already have this information
isModerator := false
for _, mod := range d.Moderators { for _, mod := range d.Moderators {
if mod.Email == c.Email { if mod.Email == c.Email {
isModerator = true isModerator = true
break break
} }
} }
}
commenterHex = c.CommenterHex var state string
if isModerator {
if isModerator { state = "approved"
state = "approved" } else if d.RequireModeration {
} else { state = "unapproved"
if d.RequireModeration { } else if commenterHex == "anonymous" && d.ModerateAllAnonymous {
state = "unapproved" state = "unapproved"
} else { } else if d.AutoSpamFilter && isSpam(*x.Domain, getIp(r), getUserAgent(r), commenterName, commenterEmail, commenterLink, *x.Markdown) {
state = "approved" state = "flagged"
} } else {
} state = "approved"
} }
commentHex, err := commentNew(commenterHex, domain, path, *x.ParentHex, *x.Markdown, state, time.Now().UTC()) commentHex, err := commentNew(commenterHex, domain, path, *x.ParentHex, *x.Markdown, state, time.Now().UTC())
@@ -126,5 +120,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "approved": state == "approved"}) // TODO: reuse html in commentNew and do only one markdown to HTML conversion?
html := markdownToHtml(*x.Markdown)
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": html})
if smtpConfigured {
go emailNotificationNew(d, path, commenterHex, commentHex, html, *x.ParentHex, state)
}
} }

View File

@@ -39,10 +39,10 @@ func TestCommentNewUpvoted(t *testing.T) {
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC()) commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
statement := ` statement := `
SELECT score SELECT score
FROM comments FROM comments
WHERE commentHex = $1; WHERE commentHex = $1;
` `
row := db.QueryRow(statement, commentHex) row := db.QueryRow(statement, commentHex)
var score int var score int

View File

@@ -19,7 +19,7 @@ func commentVote(commenterHex string, commentHex string, direction int) error {
var authorHex string var authorHex string
if err := row.Scan(&authorHex); err != nil { if err := row.Scan(&authorHex); err != nil {
logger.Errorf("erorr selecting authorHex for vote") logger.Errorf("error selecting authorHex for vote")
return errorInternal return errorInternal
} }
@@ -28,12 +28,12 @@ func commentVote(commenterHex string, commentHex string, direction int) error {
} }
statement = ` statement = `
INSERT INTO INSERT INTO
votes (commentHex, commenterHex, direction, voteDate) votes (commentHex, commenterHex, direction, voteDate)
VALUES ($1, $2, $3, $4 ) VALUES ($1, $2, $3, $4 )
ON CONFLICT (commentHex, commenterHex) DO ON CONFLICT (commentHex, commenterHex) DO
UPDATE SET direction = $3; UPDATE SET direction = $3;
` `
_, err := db.Exec(statement, commentHex, commenterHex, direction, time.Now().UTC()) _, err := db.Exec(statement, commentHex, commenterHex, direction, time.Now().UTC())
if err != nil { if err != nil {
logger.Errorf("error inserting/updating votes: %v", err) logger.Errorf("error inserting/updating votes: %v", err)

View File

@@ -12,4 +12,5 @@ type commenter struct {
Photo string `json:"photo"` Photo string `json:"photo"`
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
JoinDate time.Time `json:"joinDate,omitempty"` JoinDate time.Time `json:"joinDate,omitempty"`
IsModerator bool `json:"isModerator"`
} }

View File

@@ -2,20 +2,42 @@ package main
import () import ()
var commentersRowColumns string = `
commenters.commenterHex,
commenters.email,
commenters.name,
commenters.link,
commenters.photo,
commenters.provider,
commenters.joinDate
`
func commentersRowScan(s sqlScanner, c *commenter) error {
return s.Scan(
&c.CommenterHex,
&c.Email,
&c.Name,
&c.Link,
&c.Photo,
&c.Provider,
&c.JoinDate,
)
}
func commenterGetByHex(commenterHex string) (commenter, error) { func commenterGetByHex(commenterHex string) (commenter, error) {
if commenterHex == "" { if commenterHex == "" {
return commenter{}, errorMissingField return commenter{}, errorMissingField
} }
statement := ` statement := `
SELECT commenterHex, email, name, link, photo, provider, joinDate SELECT ` + commentersRowColumns + `
FROM commenters FROM commenters
WHERE commenterHex = $1; WHERE commenterHex = $1;
` `
row := db.QueryRow(statement, commenterHex) row := db.QueryRow(statement, commenterHex)
c := commenter{} var c commenter
if err := row.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil { if err := commentersRowScan(row, &c); err != nil {
// TODO: is this the only error? // TODO: is this the only error?
return commenter{}, errorNoSuchCommenter return commenter{}, errorNoSuchCommenter
} }
@@ -29,14 +51,14 @@ func commenterGetByEmail(provider string, email string) (commenter, error) {
} }
statement := ` statement := `
SELECT commenterHex, email, name, link, photo, provider, joinDate SELECT ` + commentersRowColumns + `
FROM commenters FROM commenters
WHERE email = $1 AND provider = $2; WHERE email = $1 AND provider = $2;
` `
row := db.QueryRow(statement, email, provider) row := db.QueryRow(statement, email, provider)
c := commenter{} var c commenter
if err := row.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil { if err := commentersRowScan(row, &c); err != nil {
// TODO: is this the only error? // TODO: is this the only error?
return commenter{}, errorNoSuchCommenter return commenter{}, errorNoSuchCommenter
} }
@@ -50,21 +72,22 @@ func commenterGetByCommenterToken(commenterToken string) (commenter, error) {
} }
statement := ` statement := `
SELECT commenterHex SELECT ` + commentersRowColumns + `
FROM commenterSessions FROM commenterSessions
WHERE commenterToken = $1; JOIN commenters ON commenterSessions.commenterHex = commenters.commenterHex
WHERE commenterToken = $1;
` `
row := db.QueryRow(statement, commenterToken) row := db.QueryRow(statement, commenterToken)
var commenterHex string var c commenter
if err := row.Scan(&commenterHex); err != nil { if err := commentersRowScan(row, &c); err != nil {
// TODO: is the only error? // TODO: is this the only error?
return commenter{}, errorNoSuchToken return commenter{}, errorNoSuchToken
} }
if commenterHex == "none" { if c.CommenterHex == "none" {
return commenter{}, errorNoSuchToken return commenter{}, errorNoSuchToken
} }
return commenterGetByHex(commenterHex) return c, nil
} }

View File

@@ -67,5 +67,18 @@ func commenterLoginHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken}) // TODO: modify commenterLogin to directly return c?
c, err := commenterGetByCommenterToken(commenterToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
e, err := emailGet(c.Email)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken, "commenter": c, "email": e})
} }

View File

@@ -27,6 +27,10 @@ func commenterNew(email string, name string, link string, photo string, provider
return "", errorEmailAlreadyExists return "", errorEmailAlreadyExists
} }
if err := emailNew(email); err != nil {
return "", errorInternal
}
commenterHex, err := randomHex(32) commenterHex, err := randomHex(32)
if err != nil { if err != nil {
return "", errorInternal return "", errorInternal

59
api/commenter_photo.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"fmt"
"image/jpeg"
"io"
"net/http"
"strings"
"github.com/disintegration/imaging"
)
func commenterPhotoHandler(w http.ResponseWriter, r *http.Request) {
c, err := commenterGetByHex(r.FormValue("commenterHex"))
if err != nil {
http.NotFound(w, r)
return
}
url := c.Photo
if c.Provider == "google" {
if strings.HasSuffix(url, "photo.jpg") {
url += "?sz=38"
} else {
url += "=s38"
}
} else if c.Provider == "github" {
url += "&s=38"
} else if c.Provider == "gitlab" {
url += "?width=38"
}
resp, err := http.Get(url)
if err != nil {
http.NotFound(w, r)
return
}
defer resp.Body.Close()
if c.Provider != "commento" { // Custom URL avatars need to be resized.
io.Copy(w, resp.Body)
return
}
// Limit the size of the response to 128 KiB to prevent DoS attacks
// that exhaust memory.
limitedResp := &io.LimitedReader{R: resp.Body, N: 128 * 1024}
img, err := jpeg.Decode(limitedResp)
if err != nil {
fmt.Fprintf(w, "JPEG decode failed: %v\n", err)
return
}
if err = imaging.Encode(w, imaging.Resize(img, 38, 0, imaging.Lanczos), imaging.JPEG); err != nil {
fmt.Fprintf(w, "image encoding failed: %v\n", err)
return
}
}

View File

@@ -21,5 +21,11 @@ func commenterSelfHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
bodyMarshal(w, response{"success": true, "commenter": c}) e, err := emailGet(c.Email)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "commenter": c, "email": e})
} }

View File

@@ -8,10 +8,10 @@ func commenterSessionUpdate(commenterToken string, commenterHex string) error {
} }
statement := ` statement := `
UPDATE commenterSessions UPDATE commenterSessions
SET commenterHex = $2 SET commenterHex = $2
WHERE commenterToken = $1; WHERE commenterToken = $1;
` `
_, err := db.Exec(statement, commenterToken, commenterHex) _, err := db.Exec(statement, commenterToken, commenterHex)
if err != nil { if err != nil {
logger.Errorf("error updating commenterHex: %v", err) logger.Errorf("error updating commenterHex: %v", err)

69
api/commenter_update.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"net/http"
)
func commenterUpdate(commenterHex string, email string, name string, link string, photo string, provider string) error {
if email == "" || name == "" || photo == "" || provider == "" {
return errorMissingField
}
// See utils_sanitise.go's documentation on isHttpsUrl. This is not a URL
// validator, just an XSS preventor.
// TODO: reject URLs instead of malforming them.
if link == "" {
link = "undefined"
} else if link != "undefined" && !isHttpsUrl(link) {
link = "https://" + link
}
statement := `
UPDATE commenters
SET email = $3, name = $4, link = $5, photo = $6
WHERE commenterHex = $1 and provider = $2;
`
_, err := db.Exec(statement, commenterHex, provider, email, name, link, photo)
if err != nil {
logger.Errorf("cannot update commenter: %v", err)
return errorInternal
}
return nil
}
func commenterUpdateHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
CommenterToken *string `json:"commenterToken"`
Name *string `json:"name"`
Email *string `json:"email"`
Link *string `json:"link"`
Photo *string `json:"photo"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if c.Provider != "commento" {
bodyMarshal(w, response{"success": false, "message": errorCannotUpdateOauthProfile.Error()})
return
}
*x.Email = c.Email
if err = commenterUpdate(c.CommenterHex, *x.Email, *x.Name, *x.Link, *x.Photo, c.Provider); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -43,8 +43,26 @@ func configParse() error {
"SMTP_PORT": "", "SMTP_PORT": "",
"SMTP_FROM_ADDRESS": "", "SMTP_FROM_ADDRESS": "",
"AKISMET_KEY": "",
"GOOGLE_KEY": "", "GOOGLE_KEY": "",
"GOOGLE_SECRET": "", "GOOGLE_SECRET": "",
"GITHUB_KEY": "",
"GITHUB_SECRET": "",
"TWITTER_KEY": "",
"TWITTER_SECRET": "",
"GITLAB_KEY": "",
"GITLAB_SECRET": "",
"GITLAB_URL": "https://gitlab.com",
}
if os.Getenv("COMMENTO_CONFIG_FILE") != "" {
if err := configFileLoad(os.Getenv("COMMENTO_CONFIG_FILE")); err != nil {
return err
}
} }
for key, value := range defaults { for key, value := range defaults {
@@ -55,12 +73,6 @@ func configParse() error {
} }
} }
if os.Getenv("CONFIG_FILE") != "" {
if err := configFileLoad(os.Getenv("CONFIG_FILE")); err != nil {
return err
}
}
// Mandatory config parameters // Mandatory config parameters
for _, env := range []string{"POSTGRES", "PORT", "ORIGIN", "FORBID_NEW_OWNERS", "MAX_IDLE_PG_CONNECTIONS"} { for _, env := range []string{"POSTGRES", "PORT", "ORIGIN", "FORBID_NEW_OWNERS", "MAX_IDLE_PG_CONNECTIONS"} {
if os.Getenv(env) == "" { if os.Getenv(env) == "" {

View File

@@ -41,11 +41,12 @@ func configFileLoad(filepath string) error {
continue continue
} }
if os.Getenv(key[9:]) != "" { if os.Getenv(key) != "" {
// Config files have lower precedence.
continue continue
} }
os.Setenv(key[9:], value) os.Setenv(key, value)
} }
return nil return nil

View File

@@ -37,19 +37,19 @@ func TestConfigFileLoadBasics(t *testing.T) {
return return
} }
os.Setenv("PORT", "9000") os.Setenv("COMMENTO_PORT", "9000")
if err := configFileLoad(f.Name()); err != nil { if err := configFileLoad(f.Name()); err != nil {
t.Errorf("unexpected error loading config file: %v", err) t.Errorf("unexpected error loading config file: %v", err)
return return
} }
if os.Getenv("PORT") != "9000" { if os.Getenv("COMMENTO_PORT") != "9000" {
t.Errorf("expected PORT=9000 got PORT=%s", os.Getenv("PORT")) t.Errorf("expected COMMENTO_PORT=9000 got COMMENTO_PORT=%s", os.Getenv("COMMENTO_PORT"))
return return
} }
if os.Getenv("GZIP_STATIC") != "true" { if os.Getenv("COMMENTO_GZIP_STATIC") != "true" {
t.Errorf("expected GZIP_STATIC=true got GZIP_STATIC=%s", os.Getenv("GZIP_STATIC")) t.Errorf("expected COMMENTO_GZIP_STATIC=true got COMMENTO_GZIP_STATIC=%s", os.Getenv("COMMENTO_GZIP_STATIC"))
return return
} }
} }

View File

@@ -21,6 +21,7 @@ func TestConfigParseBasics(t *testing.T) {
os.Setenv("COMMENTO_BIND_ADDRESS", "192.168.1.100") os.Setenv("COMMENTO_BIND_ADDRESS", "192.168.1.100")
os.Setenv("COMMENTO_PORT", "")
if err := configParse(); err != nil { if err := configParse(); err != nil {
t.Errorf("unexpected error when parsing config: %v", err) t.Errorf("unexpected error when parsing config: %v", err)
return return

View File

@@ -1,4 +1,3 @@
package main package main
var edition = "ce" var version string
var version = "v1.1.1"

View File

@@ -0,0 +1,25 @@
package main
import (
"time"
)
func domainExportCleanupBegin() error {
go func() {
for {
statement := `
DELETE FROM exports
WHERE creationDate < $1;
`
_, err := db.Exec(statement, time.Now().UTC().AddDate(0, 0, -7))
if err != nil {
logger.Errorf("error cleaning up export rows: %v", err)
return
}
time.Sleep(2 * time.Hour)
}
}()
return nil
}

25
api/cron_sso_token.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"time"
)
func ssoTokenCleanupBegin() error {
go func() {
for {
statement := `
DELETE FROM ssoTokens
WHERE creationDate < $1;
`
_, err := db.Exec(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute))
if err != nil {
logger.Errorf("error cleaning up export rows: %v", err)
return
}
time.Sleep(10 * time.Minute)
}
}()
return nil
}

25
api/cron_views_cleanup.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"time"
)
func viewsCleanupBegin() error {
go func() {
for {
statement := `
DELETE FROM views
WHERE viewDate < $1;
`
_, err := db.Exec(statement, time.Now().UTC().AddDate(0, 0, -45))
if err != nil {
logger.Errorf("error cleaning up views: %v", err)
return
}
time.Sleep(24 * time.Hour)
}
}()
return nil
}

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"database/sql" "database/sql"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"net/url"
"os" "os"
"strconv" "strconv"
"time" "time"
@@ -10,9 +11,14 @@ import (
func dbConnect(retriesLeft int) error { func dbConnect(retriesLeft int) error {
con := os.Getenv("POSTGRES") con := os.Getenv("POSTGRES")
logger.Infof("opening connection to postgres: %s", con) u, err := url.Parse(con)
if err != nil {
logger.Errorf("invalid postgres connection URI: %v", err)
return err
}
u.User = url.UserPassword(u.User.Username(), "redacted")
logger.Infof("opening connection to postgres: %s", u.String())
var err error
db, err = sql.Open("postgres", con) db, err = sql.Open("postgres", con)
if err != nil { if err != nil {
logger.Errorf("cannot open connection to postgres: %v", err) logger.Errorf("cannot open connection to postgres: %v", err)
@@ -32,10 +38,10 @@ func dbConnect(retriesLeft int) error {
} }
statement := ` statement := `
CREATE TABLE IF NOT EXISTS migrations ( CREATE TABLE IF NOT EXISTS migrations (
filename TEXT NOT NULL UNIQUE filename TEXT NOT NULL UNIQUE
); );
` `
_, err = db.Exec(statement) _, err = db.Exec(statement)
if err != nil { if err != nil {
logger.Errorf("cannot create migrations table: %v", err) logger.Errorf("cannot create migrations table: %v", err)

View File

@@ -6,6 +6,10 @@ import (
"strings" "strings"
) )
var goMigrations = map[string](func() error){
"20190213033530-email-notifications.sql": migrateEmails,
}
func migrate() error { func migrate() error {
return migrateFromDir(os.Getenv("STATIC") + "/db") return migrateFromDir(os.Getenv("STATIC") + "/db")
} }
@@ -18,9 +22,9 @@ func migrateFromDir(dir string) error {
} }
statement := ` statement := `
SELECT filename SELECT filename
FROM migrations; FROM migrations;
` `
rows, err := db.Query(statement) rows, err := db.Query(statement)
if err != nil { if err != nil {
logger.Errorf("cannot query migrations: %v", err) logger.Errorf("cannot query migrations: %v", err)
@@ -59,16 +63,23 @@ func migrateFromDir(dir string) error {
} }
statement = ` statement = `
INSERT INTO INSERT INTO
migrations (filename) migrations (filename)
VALUES ($1 ); VALUES ($1 );
` `
_, err = db.Exec(statement, file.Name()) _, err = db.Exec(statement, file.Name())
if err != nil { if err != nil {
logger.Errorf("cannot insert filename into the migrations table: %v", err) logger.Errorf("cannot insert filename into the migrations table: %v", err)
return err return err
} }
if fn, ok := goMigrations[file.Name()]; ok {
if err = fn(); err != nil {
logger.Errorf("cannot execute Go migration associated with SQL %s: %v", f, err)
return err
}
}
completed++ completed++
} }
} }

View File

@@ -0,0 +1,37 @@
package main
import ()
func migrateEmails() error {
statement := `
SELECT commenters.email
FROM commenters
UNION
SELECT owners.email
FROM owners
UNION
SELECT moderators.email
FROM moderators;
`
rows, err := db.Query(statement)
if err != nil {
logger.Errorf("cannot get comments: %v", err)
return errorDatabaseMigration
}
defer rows.Close()
for rows.Next() {
var email string
if err = rows.Scan(&email); err != nil {
logger.Errorf("cannot get email from tables during migration: %v", err)
return errorDatabaseMigration
}
if err = emailNew(email); err != nil {
logger.Errorf("cannot insert email during migration: %v", err)
return errorDatabaseMigration
}
}
return nil
}

View File

@@ -5,14 +5,25 @@ import (
) )
type domain struct { type domain struct {
Domain string `json:"domain"` Domain string `json:"domain"`
OwnerHex string `json:"ownerHex"` OwnerHex string `json:"ownerHex"`
Name string `json:"name"` Name string `json:"name"`
CreationDate time.Time `json:"creationDate"` CreationDate time.Time `json:"creationDate"`
State string `json:"state"` State string `json:"state"`
ImportedComments bool `json:"importedComments"` ImportedComments bool `json:"importedComments"`
AutoSpamFilter bool `json:"autoSpamFilter"` AutoSpamFilter bool `json:"autoSpamFilter"`
RequireModeration bool `json:"requireModeration"` RequireModeration bool `json:"requireModeration"`
RequireIdentification bool `json:"requireIdentification"` RequireIdentification bool `json:"requireIdentification"`
Moderators []moderator `json:"moderators"` ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
Moderators []moderator `json:"moderators"`
EmailNotificationPolicy string `json:"emailNotificationPolicy"`
CommentoProvider bool `json:"commentoProvider"`
GoogleProvider bool `json:"googleProvider"`
TwitterProvider bool `json:"twitterProvider"`
GithubProvider bool `json:"githubProvider"`
GitlabProvider bool `json:"gitlabProvider"`
SsoProvider bool `json:"ssoProvider"`
SsoSecret string `json:"ssoSecret"`
SsoUrl string `json:"ssoUrl"`
DefaultSortPolicy string `json:"defaultSortPolicy"`
} }

82
api/domain_clear.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"net/http"
)
func domainClear(domain string) error {
if domain == "" {
return errorMissingField
}
statement := `
DELETE FROM votes
USING comments
WHERE comments.commentHex = votes.commentHex AND comments.domain = $1;
`
_, err := db.Exec(statement, domain)
if err != nil {
logger.Errorf("cannot delete votes: %v", err)
return errorInternal
}
statement = `
DELETE FROM comments
WHERE comments.domain = $1;
`
_, err = db.Exec(statement, domain)
if err != nil {
logger.Errorf(statement, domain)
return errorInternal
}
statement = `
DELETE FROM pages
WHERE pages.domain = $1;
`
_, err = db.Exec(statement, domain)
if err != nil {
logger.Errorf(statement, domain)
return errorInternal
}
return nil
}
func domainClearHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
OwnerToken *string `json:"ownerToken"`
Domain *string `json:"domain"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
o, err := ownerGetByOwnerToken(*x.OwnerToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
domain := domainStrip(*x.Domain)
isOwner, err := domainOwnershipVerify(o.OwnerHex, domain)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if !isOwner {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
if err = domainClear(*x.Domain); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -10,8 +10,7 @@ func domainDelete(domain string) error {
} }
statement := ` statement := `
DELETE FROM DELETE FROM domains
domains
WHERE domain = $1; WHERE domain = $1;
` `
_, err := db.Exec(statement, domain) _, err := db.Exec(statement, domain)
@@ -19,24 +18,13 @@ func domainDelete(domain string) error {
return errorNoSuchDomain return errorNoSuchDomain
} }
statement = `
DELETE FROM votes
USING comments
WHERE comments.commentHex = votes.commentHex AND comments.domain = $1;
`
_, err = db.Exec(statement, domain)
if err != nil {
logger.Errorf("cannot delete votes: %v", err)
return errorInternal
}
statement = ` statement = `
DELETE FROM views DELETE FROM views
WHERE views.domain = $1; WHERE views.domain = $1;
` `
_, err = db.Exec(statement, domain) _, err = db.Exec(statement, domain)
if err != nil { if err != nil {
logger.Errorf("cannot delete views: %v", err) logger.Errorf("cannot delete domain from views: %v", err)
return errorInternal return errorInternal
} }
@@ -46,17 +34,23 @@ func domainDelete(domain string) error {
` `
_, err = db.Exec(statement, domain) _, err = db.Exec(statement, domain)
if err != nil { if err != nil {
logger.Errorf("cannot delete domain moderators: %v", err) logger.Errorf("cannot delete domain from moderators: %v", err)
return errorInternal return errorInternal
} }
statement = ` statement = `
DELETE FROM comments DELETE FROM ssotokens
WHERE comments.domain = $1; WHERE ssotokens.domain = $1;
` `
_, err = db.Exec(statement, domain) _, err = db.Exec(statement, domain)
if err != nil { if err != nil {
logger.Errorf(statement, domain) logger.Errorf("cannot delete domain from ssotokens: %v", err)
return errorInternal
}
// comments, votes, and pages are handled by domainClear
if err = domainClear(domain); err != nil {
logger.Errorf("cannot clear domain: %v", err)
return errorInternal return errorInternal
} }

145
api/domain_export.go Normal file
View File

@@ -0,0 +1,145 @@
package main
import (
"encoding/json"
"net/http"
"time"
)
func domainExportBeginError(email string, toName string, domain string, err error) {
// we're not using err at the moment because it's all errorInternal
if err2 := smtpDomainExportError(email, toName, domain); err2 != nil {
logger.Errorf("cannot send domain export error email for %s: %v", domain, err2)
return
}
}
func domainExportBegin(email string, toName string, domain string) {
e := commentoExportV1{Version: 1, Comments: []comment{}, Commenters: []commenter{}}
statement := `
SELECT commentHex, domain, path, commenterHex, markdown, parentHex, score, state, creationDate
FROM comments
WHERE domain = $1;
`
rows1, err := db.Query(statement, domain)
if err != nil {
logger.Errorf("cannot select comments while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
defer rows1.Close()
for rows1.Next() {
c := comment{}
if err = rows1.Scan(&c.CommentHex, &c.Domain, &c.Path, &c.CommenterHex, &c.Markdown, &c.ParentHex, &c.Score, &c.State, &c.CreationDate); err != nil {
logger.Errorf("cannot scan comment while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
e.Comments = append(e.Comments, c)
}
statement = `
SELECT commenters.commenterHex, commenters.email, commenters.name, commenters.link, commenters.photo, commenters.provider, commenters.joinDate
FROM commenters, comments
WHERE comments.domain = $1 AND commenters.commenterHex = comments.commenterHex;
`
rows2, err := db.Query(statement, domain)
if err != nil {
logger.Errorf("cannot select commenters while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
defer rows2.Close()
for rows2.Next() {
c := commenter{}
if err := rows2.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil {
logger.Errorf("cannot scan commenter while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
e.Commenters = append(e.Commenters, c)
}
je, err := json.Marshal(e)
if err != nil {
logger.Errorf("cannot marshall JSON while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
gje, err := gzipStatic(je)
if err != nil {
logger.Errorf("cannot gzip JSON while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
exportHex, err := randomHex(32)
if err != nil {
logger.Errorf("cannot generate exportHex while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
statement = `
INSERT INTO
exports (exportHex, binData, domain, creationDate)
VALUES ($1, $2, $3 , $4 );
`
_, err = db.Exec(statement, exportHex, gje, domain, time.Now().UTC())
if err != nil {
logger.Errorf("error inserting expiry binary data while exporting %s: %v", domain, err)
domainExportBeginError(email, toName, domain, errorInternal)
return
}
err = smtpDomainExport(email, toName, domain, exportHex)
if err != nil {
logger.Errorf("error sending data export email for %s: %v", domain, err)
return
}
}
func domainExportBeginHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
OwnerToken *string `json:"ownerToken"`
Domain *string `json:"domain"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if !smtpConfigured {
bodyMarshal(w, response{"success": false, "message": errorSmtpNotConfigured.Error()})
return
}
o, err := ownerGetByOwnerToken(*x.OwnerToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
isOwner, err := domainOwnershipVerify(o.OwnerHex, *x.Domain)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if !isOwner {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
go domainExportBegin(o.Email, o.Name, *x.Domain)
bodyMarshal(w, response{"success": true})
}

View File

@@ -0,0 +1,32 @@
package main
import (
"fmt"
"net/http"
"time"
)
func domainExportDownloadHandler(w http.ResponseWriter, r *http.Request) {
exportHex := r.FormValue("exportHex")
if exportHex == "" {
fmt.Fprintf(w, "Error: empty exportHex\n")
return
}
statement := `
SELECT domain, binData, creationDate
FROM exports
WHERE exportHex = $1;
`
row := db.QueryRow(statement, exportHex)
var domain string
var binData []byte
var creationDate time.Time
if err := row.Scan(&domain, &binData, &creationDate); err != nil {
fmt.Fprintf(w, "Error: that exportHex does not exist\n")
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s-%v.json.gz"`, domain, creationDate.Unix()))
w.Write(binData)
}

View File

@@ -2,13 +2,61 @@ package main
import () import ()
var domainsRowColumns = `
domains.domain,
domains.ownerHex,
domains.name,
domains.creationDate,
domains.state,
domains.importedComments,
domains.autoSpamFilter,
domains.requireModeration,
domains.requireIdentification,
domains.moderateAllAnonymous,
domains.emailNotificationPolicy,
domains.commentoProvider,
domains.googleProvider,
domains.twitterProvider,
domains.githubProvider,
domains.gitlabProvider,
domains.ssoProvider,
domains.ssoSecret,
domains.ssoUrl,
domains.defaultSortPolicy
`
func domainsRowScan(s sqlScanner, d *domain) error {
return s.Scan(
&d.Domain,
&d.OwnerHex,
&d.Name,
&d.CreationDate,
&d.State,
&d.ImportedComments,
&d.AutoSpamFilter,
&d.RequireModeration,
&d.RequireIdentification,
&d.ModerateAllAnonymous,
&d.EmailNotificationPolicy,
&d.CommentoProvider,
&d.GoogleProvider,
&d.TwitterProvider,
&d.GithubProvider,
&d.GitlabProvider,
&d.SsoProvider,
&d.SsoSecret,
&d.SsoUrl,
&d.DefaultSortPolicy,
)
}
func domainGet(dmn string) (domain, error) { func domainGet(dmn string) (domain, error) {
if dmn == "" { if dmn == "" {
return domain{}, errorMissingField return domain{}, errorMissingField
} }
statement := ` statement := `
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification SELECT ` + domainsRowColumns + `
FROM domains FROM domains
WHERE domain = $1; WHERE domain = $1;
` `
@@ -16,7 +64,7 @@ func domainGet(dmn string) (domain, error) {
var err error var err error
d := domain{} d := domain{}
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification); err != nil { if err = domainsRowScan(row, &d); err != nil {
return d, errorNoSuchDomain return d, errorNoSuchDomain
} }

View File

@@ -0,0 +1,168 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"io/ioutil"
"net/http"
)
type commentoExportV1 struct {
Version int `json:"version"`
Comments []comment `json:"comments"`
Commenters []commenter `json:"commenters"`
}
func domainImportCommento(domain string, url string) (int, error) {
if domain == "" || url == "" {
return 0, errorMissingField
}
resp, err := http.Get(url)
if err != nil {
logger.Errorf("cannot get url: %v", err)
return 0, errorCannotDownloadCommento
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
logger.Errorf("cannot read body: %v", err)
return 0, errorCannotDownloadCommento
}
zr, err := gzip.NewReader(bytes.NewBuffer(body))
if err != nil {
logger.Errorf("cannot create gzip reader: %v", err)
return 0, errorInternal
}
contents, err := ioutil.ReadAll(zr)
if err != nil {
logger.Errorf("cannot read gzip contents uncompressed: %v", err)
return 0, errorInternal
}
var data commentoExportV1
if err := json.Unmarshal(contents, &data); err != nil {
logger.Errorf("cannot unmarshal JSON at %s: %v", url, err)
return 0, errorInternal
}
if data.Version != 1 {
logger.Errorf("invalid data version (got %d, want 1): %v", data.Version, err)
return 0, errorUnsupportedCommentoImportVersion
}
// Check if imported commentedHex or email exists, creating a map of
// commenterHex (old hex, new hex)
commenterHex := map[string]string{"anonymous": "anonymous"}
for _, commenter := range data.Commenters {
c, err := commenterGetByEmail("commento", commenter.Email)
if err != nil && err != errorNoSuchCommenter {
logger.Errorf("cannot get commenter by email: %v", err)
return 0, errorInternal
}
if err == nil {
commenterHex[commenter.CommenterHex] = c.CommenterHex
continue
}
randomPassword, err := randomHex(32)
if err != nil {
logger.Errorf("cannot generate random password for new commenter: %v", err)
return 0, errorInternal
}
commenterHex[commenter.CommenterHex], err = commenterNew(commenter.Email,
commenter.Name, commenter.Link, commenter.Photo, "commento", randomPassword)
if err != nil {
return 0, err
}
}
// Create a map of (parent hex, comments)
comments := make(map[string][]comment)
for _, comment := range data.Comments {
parentHex := comment.ParentHex
comments[parentHex] = append(comments[parentHex], comment)
}
// Import comments, creating a map of comment hex (old hex, new hex)
commentHex := map[string]string{"root": "root"}
numImported := 0
keys := []string{"root"}
for i := 0; i < len(keys); i++ {
for _, comment := range comments[keys[i]] {
cHex, ok := commenterHex[comment.CommenterHex]
if !ok {
logger.Errorf("cannot get commenter: %v", err)
return numImported, errorInternal
}
parentHex, ok := commentHex[comment.ParentHex]
if !ok {
logger.Errorf("cannot get parent comment: %v", err)
return numImported, errorInternal
}
hex, err := commentNew(
cHex,
domain,
comment.Path,
parentHex,
comment.Markdown,
comment.State,
comment.CreationDate)
if err != nil {
return numImported, err
}
commentHex[comment.CommentHex] = hex
numImported++
keys = append(keys, comment.CommentHex)
}
}
return numImported, nil
}
func domainImportCommentoHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
OwnerToken *string `json:"ownerToken"`
Domain *string `json:"domain"`
URL *string `json:"url"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
o, err := ownerGetByOwnerToken(*x.OwnerToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
domain := domainStrip(*x.Domain)
isOwner, err := domainOwnershipVerify(o.OwnerHex, domain)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if !isOwner {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
numImported, err := domainImportCommento(domain, *x.URL)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "numImported": numImported})
}

View File

@@ -0,0 +1,121 @@
package main
import (
"compress/gzip"
"encoding/json"
"fmt"
"net"
"net/http"
"testing"
"time"
)
func TestImportCommento(t *testing.T) {
failTestOnError(t, setupTestEnv())
// Create JSON data
data := commentoExportV1{
Version: 1,
Comments: []comment{
{
CommentHex: "5a349182b3b8e25107ab2b12e514f40fe0b69160a334019491d7c204aff6fdc2",
Domain: "localhost:1313",
Path: "/post/first-post/",
CommenterHex: "anonymous",
Markdown: "This is a reply!",
Html: "",
ParentHex: "7ed60b1227f6c4850258a2ac0304e1936770117d6f3a379655f775c46b9f13cd",
Score: 0,
State: "approved",
CreationDate: timeParse(t, "2020-01-27T14:08:44.061525Z"),
Direction: 0,
Deleted: false,
},
{
CommentHex: "7ed60b1227f6c4850258a2ac0304e1936770117d6f3a379655f775c46b9f13cd",
Domain: "localhost:1313",
Path: "/post/first-post/",
CommenterHex: "anonymous",
Markdown: "This is a comment!",
Html: "",
ParentHex: "root",
Score: 0,
State: "approved",
CreationDate: timeParse(t, "2020-01-27T14:07:49.244432Z"),
Direction: 0,
Deleted: false,
},
{
CommentHex: "a7c84f251b5a09d5b65e902cbe90633646437acefa3a52b761fee94002ac54c7",
Domain: "localhost:1313",
Path: "/post/first-post/",
CommenterHex: "4629a8216538b73987597d66f266c1a1801b0451f99cf066e7122aa104ef3b07",
Markdown: "This is a test comment, bar foo\n\n#Here is something big\n\n```\nhere code();\n```",
Html: "",
ParentHex: "root",
Score: 0,
State: "approved",
CreationDate: timeParse(t, "2020-01-27T14:20:21.101653Z"),
Direction: 0,
Deleted: false,
},
},
Commenters: []commenter{
{
CommenterHex: "4629a8216538b73987597d66f266c1a1801b0451f99cf066e7122aa104ef3b07",
Email: "john@doe.com",
Name: "John Doe",
Link: "https://john.doe",
Photo: "undefined",
Provider: "commento",
JoinDate: timeParse(t, "2020-01-27T14:17:59.298737Z"),
IsModerator: false,
},
},
}
// Create listener with random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Errorf("couldn't create listener: %v", err)
return
}
defer func() {
_ = listener.Close()
}()
port := listener.Addr().(*net.TCPAddr).Port
// Launch http server serving commento json gzipped data
go func() {
http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gzipper := gzip.NewWriter(w)
defer func() {
_ = gzipper.Close()
}()
encoder := json.NewEncoder(gzipper)
if err := encoder.Encode(data); err != nil {
t.Errorf("couldn't write data: %v", err)
}
}))
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
domainNew("temp-owner-hex", "Example", "example.com")
n, err := domainImportCommento("example.com", url)
if err != nil {
t.Errorf("unexpected error importing comments: %v", err)
return
}
if n != len(data.Comments) {
t.Errorf("imported comments missmatch (got %d, want %d)", n, len(data.Comments))
}
}
func timeParse(t *testing.T, s string) time.Time {
time, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
t.Errorf("couldn't parse time: %v", err)
}
return time
}

View File

@@ -18,9 +18,9 @@ type disqusThread struct {
type disqusAuthor struct { type disqusAuthor struct {
XMLName xml.Name `xml:"author"` XMLName xml.Name `xml:"author"`
IsAnonymous bool `xml:"isAnonymous"`
Name string `xml:"name"` Name string `xml:"name"`
Email string `xml:"email"` IsAnonymous bool `xml:"isAnonymous"`
Username string `xml:"username"`
} }
type disqusThreadId struct { type disqusThreadId struct {
@@ -43,7 +43,6 @@ type disqusPost struct {
Id string `xml:"http://disqus.com/disqus-internals id,attr"` Id string `xml:"http://disqus.com/disqus-internals id,attr"`
ThreadId disqusThreadId `xml:"thread"` ThreadId disqusThreadId `xml:"thread"`
ParentId disqusParentId `xml:"parent"` ParentId disqusParentId `xml:"parent"`
PostId disqusPostId `xml:"post"`
Message string `xml:"message"` Message string `xml:"message"`
CreationDate time.Time `xml:"createdAt"` CreationDate time.Time `xml:"createdAt"`
IsDeleted bool `xml:"isDeleted"` IsDeleted bool `xml:"isDeleted"`
@@ -98,24 +97,26 @@ func domainImportDisqus(domain string, url string) (int, error) {
// Map Disqus emails to commenterHex (if not available, create a new one // Map Disqus emails to commenterHex (if not available, create a new one
// with a random password that can be reset later). // with a random password that can be reset later).
commenterHex := make(map[string]string) commenterHex := map[string]string{}
for _, post := range x.Posts { for _, post := range x.Posts {
if post.IsDeleted || post.IsSpam { if post.IsDeleted || post.IsSpam {
continue continue
} }
if _, ok := commenterHex[post.Author.Email]; ok { email := post.Author.Username + "@disqus.com"
if _, ok := commenterHex[email]; ok {
continue continue
} }
c, err := commenterGetByEmail("commento", post.Author.Email) c, err := commenterGetByEmail("commento", email)
if err != nil && err != errorNoSuchCommenter { if err != nil && err != errorNoSuchCommenter {
logger.Errorf("cannot get commenter by email: %v", err) logger.Errorf("cannot get commenter by email: %v", err)
return 0, errorInternal return 0, errorInternal
} }
if err == nil { if err == nil {
commenterHex[post.Author.Email] = c.CommenterHex commenterHex[email] = c.CommenterHex
continue continue
} }
@@ -125,7 +126,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
return 0, errorInternal return 0, errorInternal
} }
commenterHex[post.Author.Email], err = commenterNew(post.Author.Email, post.Author.Name, "undefined", "undefined", "commento", randomPassword) commenterHex[email], err = commenterNew(email, post.Author.Name, "undefined", "undefined", "commento", randomPassword)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -134,12 +135,17 @@ func domainImportDisqus(domain string, url string) (int, error) {
// For each Disqus post, create a Commento comment. Attempt to convert the // For each Disqus post, create a Commento comment. Attempt to convert the
// HTML to markdown. // HTML to markdown.
numImported := 0 numImported := 0
disqusIdMap := make(map[string]string) disqusIdMap := map[string]string{}
for _, post := range x.Posts { for _, post := range x.Posts {
if post.IsDeleted || post.IsSpam { if post.IsDeleted || post.IsSpam {
continue continue
} }
cHex := "anonymous"
if !post.Author.IsAnonymous {
cHex = commenterHex[post.Author.Username+"@disqus.com"]
}
parentHex := "root" parentHex := "root"
if val, ok := disqusIdMap[post.ParentId.Id]; ok { if val, ok := disqusIdMap[post.ParentId.Id]; ok {
parentHex = val parentHex = val
@@ -148,7 +154,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
// TODO: restrict the list of tags to just the basics: <a>, <b>, <i>, <code> // TODO: restrict the list of tags to just the basics: <a>, <b>, <i>, <code>
// Especially remove <img> (convert it to <a>). // Especially remove <img> (convert it to <a>).
commentHex, err := commentNew( commentHex, err := commentNew(
commenterHex[post.Author.Email], cHex,
domain, domain,
pathStrip(threads[post.ThreadId.Id].URL), pathStrip(threads[post.ThreadId.Id].URL),
parentHex, parentHex,
@@ -159,7 +165,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
return numImported, err return numImported, err
} }
disqusIdMap[post.PostId.Id] = commentHex disqusIdMap[post.Id] = commentHex
numImported += 1 numImported += 1
} }

View File

@@ -10,7 +10,7 @@ func domainList(ownerHex string) ([]domain, error) {
} }
statement := ` statement := `
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification SELECT ` + domainsRowColumns + `
FROM domains FROM domains
WHERE ownerHex=$1; WHERE ownerHex=$1;
` `
@@ -23,8 +23,8 @@ func domainList(ownerHex string) ([]domain, error) {
domains := []domain{} domains := []domain{}
for rows.Next() { for rows.Next() {
d := domain{} var d domain
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification); err != nil { if err = domainsRowScan(rows, &d); err != nil {
logger.Errorf("cannot Scan domain: %v", err) logger.Errorf("cannot Scan domain: %v", err)
return nil, errorInternal return nil, errorInternal
} }
@@ -63,5 +63,14 @@ func domainListHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
bodyMarshal(w, response{"success": true, "domains": domains}) bodyMarshal(w, response{
"success": true,
"domains": domains,
"configuredOauths": map[string]bool{
"google": googleConfigured,
"twitter": twitterConfigured,
"github": githubConfigured,
"gitlab": gitlabConfigured,
},
})
} }

View File

@@ -10,6 +10,11 @@ func domainModeratorNew(domain string, email string) error {
return errorMissingField return errorMissingField
} }
if err := emailNew(email); err != nil {
logger.Errorf("cannot create email when creating moderator: %v", err)
return errorInternal
}
statement := ` statement := `
INSERT INTO INSERT INTO
moderators (domain, email, addDate) moderators (domain, email, addDate)

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
) )
@@ -10,6 +11,10 @@ func domainNew(ownerHex string, name string, domain string) error {
return errorMissingField return errorMissingField
} }
if strings.Contains(domain, "/") {
return errorInvalidDomain
}
statement := ` statement := `
INSERT INTO INSERT INTO
domains (ownerHex, name, domain, creationDate) domains (ownerHex, name, domain, creationDate)

69
api/domain_sso.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"net/http"
)
func domainSsoSecretNew(domain string) (string, error) {
if domain == "" {
return "", errorMissingField
}
ssoSecret, err := randomHex(32)
if err != nil {
logger.Errorf("error generating SSO secret hex: %v", err)
return "", errorInternal
}
statement := `
UPDATE domains
SET ssoSecret = $2
WHERE domain = $1;
`
_, err = db.Exec(statement, domain, ssoSecret)
if err != nil {
logger.Errorf("cannot update ssoSecret: %v", err)
return "", errorInternal
}
return ssoSecret, nil
}
func domainSsoSecretNewHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
OwnerToken *string `json:"ownerToken"`
Domain *string `json:"domain"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
o, err := ownerGetByOwnerToken(*x.OwnerToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
domain := domainStrip(*x.Domain)
isOwner, err := domainOwnershipVerify(o.OwnerHex, domain)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if !isOwner {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
ssoSecret, err := domainSsoSecretNew(domain)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "ssoSecret": ssoSecret})
}

View File

@@ -5,13 +5,48 @@ import (
) )
func domainUpdate(d domain) error { func domainUpdate(d domain) error {
if d.SsoProvider && d.SsoUrl == "" {
return errorMissingField
}
statement := ` statement := `
UPDATE domains UPDATE domains
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6 SET
name=$2,
state=$3,
autoSpamFilter=$4,
requireModeration=$5,
requireIdentification=$6,
moderateAllAnonymous=$7,
emailNotificationPolicy=$8,
commentoProvider=$9,
googleProvider=$10,
twitterProvider=$11,
githubProvider=$12,
gitlabProvider=$13,
ssoProvider=$14,
ssoUrl=$15,
defaultSortPolicy=$16
WHERE domain=$1; WHERE domain=$1;
` `
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification) _, err := db.Exec(statement,
d.Domain,
d.Name,
d.State,
d.AutoSpamFilter,
d.RequireModeration,
d.RequireIdentification,
d.ModerateAllAnonymous,
d.EmailNotificationPolicy,
d.CommentoProvider,
d.GoogleProvider,
d.TwitterProvider,
d.GithubProvider,
d.GitlabProvider,
d.SsoProvider,
d.SsoUrl,
d.DefaultSortPolicy)
if err != nil { if err != nil {
logger.Errorf("cannot update non-moderators: %v", err) logger.Errorf("cannot update non-moderators: %v", err)
return errorInternal return errorInternal

13
api/email.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"time"
)
type email struct {
Email string `json:"email"`
UnsubscribeSecretHex string `json:"unsubscribeSecretHex"`
LastEmailNotificationDate time.Time `json:"lastEmailNotificationDate"`
SendReplyNotifications bool `json:"sendReplyNotifications"`
SendModeratorNotifications bool `json:"sendModeratorNotifications"`
}

77
api/email_get.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
"net/http"
)
var emailsRowColumns = `
emails.email,
emails.unsubscribeSecretHex,
emails.lastEmailNotificationDate,
emails.sendReplyNotifications,
emails.sendModeratorNotifications
`
func emailsRowScan(s sqlScanner, e *email) error {
return s.Scan(
&e.Email,
&e.UnsubscribeSecretHex,
&e.LastEmailNotificationDate,
&e.SendReplyNotifications,
&e.SendModeratorNotifications,
)
}
func emailGet(em string) (email, error) {
statement := `
SELECT ` + emailsRowColumns + `
FROM emails
WHERE email = $1;
`
row := db.QueryRow(statement, em)
var e email
if err := emailsRowScan(row, &e); err != nil {
// TODO: is this the only error?
return e, errorNoSuchEmail
}
return e, nil
}
func emailGetByUnsubscribeSecretHex(unsubscribeSecretHex string) (email, error) {
statement := `
SELECT ` + emailsRowColumns + `
FROM emails
WHERE unsubscribeSecretHex = $1;
`
row := db.QueryRow(statement, unsubscribeSecretHex)
e := email{}
if err := emailsRowScan(row, &e); err != nil {
// TODO: is this the only error?
return e, errorNoSuchUnsubscribeSecretHex
}
return e, nil
}
func emailGetHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
UnsubscribeSecretHex *string `json:"unsubscribeSecretHex"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
e, err := emailGetByUnsubscribeSecretHex(*x.UnsubscribeSecretHex)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "email": e})
}

88
api/email_moderate.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"fmt"
"net/http"
)
func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
unsubscribeSecretHex := r.FormValue("unsubscribeSecretHex")
action := r.FormValue("action")
commentHex := r.FormValue("commentHex")
if commentHex == "" {
fmt.Fprintf(w, "error: invalid commentHex")
return
}
statement := `
SELECT domain, deleted
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, commentHex)
var domain string
var deleted bool
if err := row.Scan(&domain, &deleted); err != nil {
// TODO: is this the only error?
fmt.Fprintf(w, "error: no such comment found (perhaps it has been deleted?)")
return
}
if deleted {
fmt.Fprintf(w, "error: that comment has already been deleted")
return
}
e, err := emailGetByUnsubscribeSecretHex(unsubscribeSecretHex)
if err != nil {
fmt.Fprintf(w, "error: %v", err.Error())
return
}
isModerator, err := isDomainModerator(domain, e.Email)
if err != nil {
logger.Errorf("error checking if %s is a moderator: %v", e.Email, err)
fmt.Fprintf(w, "error: %v", errorInternal)
return
}
if !isModerator {
fmt.Fprintf(w, "error: you're not a moderator for that domain")
return
}
// Do not use commenterGetByEmail here because we don't know which provider
// should be used. This was poor design on multiple fronts on my part, but
// let's deal with that later. For now, it suffices to match the
// deleter/approver with any account owned by the same email.
statement = `
SELECT commenterHex
FROM commenters
WHERE email = $1;
`
row = db.QueryRow(statement, e.Email)
var commenterHex string
if err = row.Scan(&commenterHex); err != nil {
logger.Errorf("cannot retrieve commenterHex by email %q: %v", e.Email, err)
fmt.Fprintf(w, "error: %v", errorInternal)
return
}
switch action {
case "approve":
err = commentApprove(commentHex)
case "delete":
err = commentDelete(commentHex, commenterHex)
default:
err = errorInvalidAction
}
if err != nil {
fmt.Fprintf(w, "error: %v", err)
return
}
fmt.Fprintf(w, "comment successfully %sd", action)
}

26
api/email_new.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"time"
)
func emailNew(email string) error {
unsubscribeSecretHex, err := randomHex(32)
if err != nil {
return errorInternal
}
statement := `
INSERT INTO
emails (email, unsubscribeSecretHex, lastEmailNotificationDate)
VALUES ($1, $2, $3 )
ON CONFLICT DO NOTHING;
`
_, err = db.Exec(statement, email, unsubscribeSecretHex, time.Now().UTC())
if err != nil {
logger.Errorf("cannot insert email into emails: %v", err)
return errorInternal
}
return nil
}

13
api/email_notification.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import ()
type emailNotification struct {
Email string
CommenterName string
Domain string
Path string
Title string
CommentHex string
Kind string
}

View File

@@ -0,0 +1,154 @@
package main
import ()
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, html string, state string) {
if d.EmailNotificationPolicy == "none" {
return
}
if d.EmailNotificationPolicy == "pending-moderation" && state == "approved" {
return
}
var commenterName string
var commenterEmail string
if commenterHex == "anonymous" {
commenterName = "Anonymous"
} else {
c, err := commenterGetByHex(commenterHex)
if err != nil {
logger.Errorf("cannot get commenter to send email notification: %v", err)
return
}
commenterName = c.Name
commenterEmail = c.Email
}
kind := d.EmailNotificationPolicy
if state != "approved" {
kind = "pending-moderation"
}
for _, m := range d.Moderators {
// Do not email the commenting moderator their own comment.
if commenterHex != "anonymous" && m.Email == commenterEmail {
continue
}
e, err := emailGet(m.Email)
if err != nil {
// No such email.
continue
}
if !e.SendModeratorNotifications {
continue
}
statement := `
SELECT name
FROM commenters
WHERE email = $1;
`
row := db.QueryRow(statement, m.Email)
var name string
if err := row.Scan(&name); err != nil {
// The moderator has probably not created a commenter account.
// We should only send emails to people who signed up, so skip.
continue
}
if err := smtpEmailNotification(m.Email, name, kind, d.Domain, path, commentHex, commenterName, title, html, e.UnsubscribeSecretHex); err != nil {
logger.Errorf("error sending email to %s: %v", m.Email, err)
continue
}
}
}
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex string, html string, parentHex string, state string) {
// No reply notifications for root comments.
if parentHex == "root" {
return
}
// No reply notification emails for unapproved comments.
if state != "approved" {
return
}
statement := `
SELECT commenterHex
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, parentHex)
var parentCommenterHex string
err := row.Scan(&parentCommenterHex)
if err != nil {
logger.Errorf("cannot scan commenterHex and parentCommenterHex: %v", err)
return
}
// No reply notification emails for anonymous users.
if parentCommenterHex == "anonymous" {
return
}
// No reply notification email for self replies.
if parentCommenterHex == commenterHex {
return
}
pc, err := commenterGetByHex(parentCommenterHex)
if err != nil {
logger.Errorf("cannot get commenter to send email notification: %v", err)
return
}
var commenterName string
if commenterHex == "anonymous" {
commenterName = "Anonymous"
} else {
c, err := commenterGetByHex(commenterHex)
if err != nil {
logger.Errorf("cannot get commenter to send email notification: %v", err)
return
}
commenterName = c.Name
}
epc, err := emailGet(pc.Email)
if err != nil {
// No such email.
return
}
if !epc.SendReplyNotifications {
return
}
smtpEmailNotification(pc.Email, pc.Name, "reply", d.Domain, path, commentHex, commenterName, title, html, epc.UnsubscribeSecretHex)
}
func emailNotificationNew(d domain, path string, commenterHex string, commentHex string, html string, parentHex string, state string) {
p, err := pageGet(d.Domain, path)
if err != nil {
logger.Errorf("cannot get page to send email notification: %v", err)
return
}
if p.Title == "" {
p.Title, err = pageTitleUpdate(d.Domain, path)
if err != nil {
// Not being able to update a page title isn't serious enough to skip an
// email notification.
p.Title = d.Domain
}
}
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, html, state)
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, html, parentHex, state)
}

39
api/email_update.go Normal file
View File

@@ -0,0 +1,39 @@
package main
import (
"net/http"
)
func emailUpdate(e email) error {
statement := `
UPDATE emails
SET sendReplyNotifications = $3, sendModeratorNotifications = $4
WHERE email = $1 AND unsubscribeSecretHex = $2;
`
_, err := db.Exec(statement, e.Email, e.UnsubscribeSecretHex, e.SendReplyNotifications, e.SendModeratorNotifications)
if err != nil {
logger.Errorf("error updating email: %v", err)
return errorInternal
}
return nil
}
func emailUpdateHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
Email *email `json:"email"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err := emailUpdate(*x.Email); err != nil {
bodyMarshal(w, response{"success": true, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -37,8 +37,19 @@ var errorNotModerator = errors.New("You need to be a moderator to do that.")
var errorNotADirectory = errors.New("The given path is not a directory.") var errorNotADirectory = errors.New("The given path is not a directory.")
var errorGzip = errors.New("Cannot GZip content.") var errorGzip = errors.New("Cannot GZip content.")
var errorCannotDownloadDisqus = errors.New("We could not download your Disqus export file.") var errorCannotDownloadDisqus = errors.New("We could not download your Disqus export file.")
var errorCannotDownloadCommento = errors.New("We could not download your Commento export file.")
var errorSelfVote = errors.New("You cannot vote on your own comment.") var errorSelfVote = errors.New("You cannot vote on your own comment.")
var errorInvalidConfigFile = errors.New("Invalid config file.") var errorInvalidConfigFile = errors.New("Invalid config file.")
var errorInvalidConfigValue = errors.New("Invalid config value.") var errorInvalidConfigValue = errors.New("Invalid config value.")
var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.") var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.")
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.") var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
var errorDatabaseMigration = errors.New("Encountered error applying database migration.")
var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.")
var errorEmptyPaths = errors.New("Empty paths field.")
var errorInvalidDomain = errors.New("Invalid domain name. Do not include the URL path after the forward slash.")
var errorInvalidEntity = errors.New("That entity does not exist.")
var errorCannotDeleteOwnerWithActiveDomains = errors.New("You cannot delete your account until all domains associated with your account are deleted.")
var errorNoSuchOwner = errors.New("No such owner.")
var errorCannotUpdateOauthProfile = errors.New("You cannot update the profile of an external account managed by third-party log in. Please use the appropriate platform to update your details.")
var errorUnsupportedCommentoImportVersion = errors.New("Unsupported Commento import format version.")
var errorInvalidAction = errors.New("Invalid action.")

97
api/forgot.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"net/http"
"time"
)
func forgot(email string, entity string) error {
if email == "" {
return errorMissingField
}
if entity != "owner" && entity != "commenter" {
return errorInvalidEntity
}
if !smtpConfigured {
return errorSmtpNotConfigured
}
var hex string
var name string
if entity == "owner" {
o, err := ownerGetByEmail(email)
if err != nil {
if err == errorNoSuchEmail {
// TODO: use a more random time instead.
time.Sleep(1 * time.Second)
return nil
} else {
logger.Errorf("cannot get owner by email: %v", err)
return errorInternal
}
}
hex = o.OwnerHex
name = o.Name
} else {
c, err := commenterGetByEmail("commento", email)
if err != nil {
if err == errorNoSuchEmail {
// TODO: use a more random time instead.
time.Sleep(1 * time.Second)
return nil
} else {
logger.Errorf("cannot get commenter by email: %v", err)
return errorInternal
}
}
hex = c.CommenterHex
name = c.Name
}
resetHex, err := randomHex(32)
if err != nil {
return err
}
var statement string
statement = `
INSERT INTO
resetHexes (resetHex, hex, entity, sendDate)
VALUES ($1, $2, $3, $4 );
`
_, err = db.Exec(statement, resetHex, hex, entity, time.Now().UTC())
if err != nil {
logger.Errorf("cannot insert resetHex: %v", err)
return errorInternal
}
err = smtpResetHex(email, name, resetHex)
if err != nil {
return err
}
return nil
}
func forgotHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
Email *string `json:"email"`
Entity *string `json:"entity"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err := forgot(*x.Email, *x.Entity); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

23
api/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module gitlab.com/commento/commento/api
go 1.12
require (
cloud.google.com/go v0.26.0 // indirect
github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047
github.com/disintegration/imaging v1.6.2
github.com/golang/protobuf v1.1.0 // indirect
github.com/gomodule/oauth1 v0.0.0-20181215000758-9a59ed3b0a84
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/handlers v1.4.0
github.com/gorilla/mux v1.6.2
github.com/lib/pq v0.0.0-20180523175426-90697d60dd84
github.com/lunny/html2md v0.0.0-20180317074532-13aaeeae9fb2
github.com/microcosm-cc/bluemonday v1.0.0
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473
github.com/russross/blackfriday v1.5.1
golang.org/x/crypto v0.0.0-20180808211826-de0752318171
golang.org/x/net v0.0.0-20180811021610-c39426892332
golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc
google.golang.org/appengine v1.1.0 // indirect
)

37
api/go.sum Normal file
View File

@@ -0,0 +1,37 @@
cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047 h1:ZC99vhH6LlWY7bstM3JhEZl1c0a0DWZPFe7+hvRwTlc=
github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047/go.mod h1:DU/mtPMgEDGGfgxGATXm2Br5+F7JOClQj9nHVKZMlns=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/golang/protobuf v1.1.0 h1:0iH4Ffd/meGoXqF2lSAhZHt8X+cPgkfn/cb6Cce5Vpc=
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomodule/oauth1 v0.0.0-20181215000758-9a59ed3b0a84 h1:NlNEdePx7QY9Z4rds4EIe1dvUT8Ao1PZgLS80S5YTbU=
github.com/gomodule/oauth1 v0.0.0-20181215000758-9a59ed3b0a84/go.mod h1:4r/a8/3RkhMBxJQWL5qzbOEcaQmNPIkNoI7P8sXeI08=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.4.0 h1:XulKRWSQK5uChr4pEgSE4Tc/OcmnU9GJuSwdog/tZsA=
github.com/gorilla/handlers v1.4.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/lib/pq v0.0.0-20180523175426-90697d60dd84 h1:it29sI2IM490luSc3RAhp5WuCYnc6RtbfLVAB7nmC5M=
github.com/lib/pq v0.0.0-20180523175426-90697d60dd84/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lunny/html2md v0.0.0-20180317074532-13aaeeae9fb2 h1:eShptbR1fYhyKFFrjdSY1QuW6ymkTLlgyNEeZMchy3s=
github.com/lunny/html2md v0.0.0-20180317074532-13aaeeae9fb2/go.mod h1:lUUaVYlpAQ1Oo6vIZfec6CXQZjOvFZLyqaR8Dl7m+hk=
github.com/microcosm-cc/bluemonday v1.0.0 h1:dr58SIfmOwOVr+m4Ye1xLWv8Dk9OFwXAtYnbJSmJ65k=
github.com/microcosm-cc/bluemonday v1.0.0/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 h1:J1QZwDXgZ4dJD2s19iqR9+U00OWM2kDzbf1O/fmvCWg=
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/russross/blackfriday v1.5.1 h1:B8ZN6pD4PVofmlDCDUdELeYrbsVIDM/bpjW3v3zgcRc=
github.com/russross/blackfriday v1.5.1/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
golang.org/x/crypto v0.0.0-20180808211826-de0752318171 h1:vYogbvSFj2YXcjQxFHu/rASSOt9sLytpCaSkiwQ135I=
golang.org/x/crypto v0.0.0-20180808211826-de0752318171/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20180811021610-c39426892332 h1:efGso+ep0DjyCBJPjvoz0HI6UldX4Md2F1rZFe1ir0E=
golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc h1:3ElrZeO6IBP+M8kgu5YFwRo92Gqr+zBg3aooYQ6ziqU=
golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=

View File

@@ -2,6 +2,7 @@ package main
func main() { func main() {
exitIfError(loggerCreate()) exitIfError(loggerCreate())
exitIfError(versionPrint())
exitIfError(configParse()) exitIfError(configParse())
exitIfError(dbConnect(5)) exitIfError(dbConnect(5))
exitIfError(migrate()) exitIfError(migrate())
@@ -11,6 +12,9 @@ func main() {
exitIfError(markdownRendererCreate()) exitIfError(markdownRendererCreate())
exitIfError(sigintCleanupSetup()) exitIfError(sigintCleanupSetup())
exitIfError(versionCheckStart()) exitIfError(versionCheckStart())
exitIfError(domainExportCleanupBegin())
exitIfError(viewsCleanupBegin())
exitIfError(ssoTokenCleanupBegin())
exitIfError(routesServe()) exitIfError(routesServe())
} }

View File

@@ -2,14 +2,27 @@ package main
import () import ()
var configuredOauths []string var googleConfigured bool
var twitterConfigured bool
var githubConfigured bool
var gitlabConfigured bool
func oauthConfigure() error { func oauthConfigure() error {
configuredOauths = []string{}
if err := googleOauthConfigure(); err != nil { if err := googleOauthConfigure(); err != nil {
return err return err
} }
if err := twitterOauthConfigure(); err != nil {
return err
}
if err := githubOauthConfigure(); err != nil {
return err
}
if err := gitlabOauthConfigure(); err != nil {
return err
}
return nil return nil
} }

43
api/oauth_github.go Normal file
View File

@@ -0,0 +1,43 @@
package main
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"os"
)
var githubConfig *oauth2.Config
func githubOauthConfigure() error {
githubConfig = nil
if os.Getenv("GITHUB_KEY") == "" && os.Getenv("GITHUB_SECRET") == "" {
return nil
}
if os.Getenv("GITHUB_KEY") == "" {
logger.Errorf("COMMENTO_GITHUB_KEY not configured, but COMMENTO_GITHUB_SECRET is set")
return errorOauthMisconfigured
}
if os.Getenv("GITHUB_SECRET") == "" {
logger.Errorf("COMMENTO_GITHUB_SECRET not configured, but COMMENTO_GITHUB_KEY is set")
return errorOauthMisconfigured
}
logger.Infof("loading github OAuth config")
githubConfig = &oauth2.Config{
RedirectURL: os.Getenv("ORIGIN") + "/api/oauth/github/callback",
ClientID: os.Getenv("GITHUB_KEY"),
ClientSecret: os.Getenv("GITHUB_SECRET"),
Scopes: []string{
"read:user",
"user:email",
},
Endpoint: github.Endpoint,
}
githubConfigured = true
return nil
}

View File

@@ -0,0 +1,131 @@
package main
import (
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"io/ioutil"
"net/http"
)
func githubGetPrimaryEmail(accessToken string) (string, error) {
resp, err := http.Get("https://api.github.com/user/emails?access_token=" + accessToken)
defer resp.Body.Close()
contents, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", errorCannotReadResponse
}
user := []map[string]interface{}{}
if err := json.Unmarshal(contents, &user); err != nil {
logger.Errorf("error unmarshaling github user: %v", err)
return "", errorInternal
}
nonPrimaryEmail := ""
for _, email := range user {
nonPrimaryEmail = email["email"].(string)
if email["primary"].(bool) {
return email["email"].(string), nil
}
}
return nonPrimaryEmail, nil
}
func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
commenterToken := r.FormValue("state")
code := r.FormValue("code")
_, err := commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
token, err := githubConfig.Exchange(oauth2.NoContext, code)
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
email, err := githubGetPrimaryEmail(token.AccessToken)
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
resp, err := http.Get("https://api.github.com/user?access_token=" + token.AccessToken)
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
defer resp.Body.Close()
contents, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(w, "Error: %s", errorCannotReadResponse.Error())
return
}
user := make(map[string]interface{})
if err := json.Unmarshal(contents, &user); err != nil {
fmt.Fprintf(w, "Error: %s", errorInternal.Error())
return
}
if email == "" {
if user["email"] == nil {
fmt.Fprintf(w, "Error: no email address returned by Github")
return
}
email = user["email"].(string)
}
name := user["login"].(string)
if user["name"] != nil {
name = user["name"].(string)
}
link := "undefined"
if user["html_url"] != nil {
link = user["html_url"].(string)
}
photo := "undefined"
if user["avatar_url"] != nil {
photo = user["avatar_url"].(string)
}
c, err := commenterGetByEmail("github", email)
if err != nil && err != errorNoSuchCommenter {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
var commenterHex string
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "github", "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "github"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}
if err := commenterSessionUpdate(commenterToken, commenterHex); err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
}

View File

@@ -0,0 +1,25 @@
package main
import (
"fmt"
"net/http"
)
func githubRedirectHandler(w http.ResponseWriter, r *http.Request) {
if githubConfig == nil {
logger.Errorf("github oauth access attempt without configuration")
fmt.Fprintf(w, "error: this website has not configured github OAuth")
return
}
commenterToken := r.FormValue("commenterToken")
_, err := commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "error: %s\n", err.Error())
return
}
url := githubConfig.AuthCodeURL(commenterToken)
http.Redirect(w, r, url, http.StatusFound)
}

44
api/oauth_gitlab.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/gitlab"
"os"
)
var gitlabConfig *oauth2.Config
func gitlabOauthConfigure() error {
gitlabConfig = nil
if os.Getenv("GITLAB_KEY") == "" && os.Getenv("GITLAB_SECRET") == "" {
return nil
}
if os.Getenv("GITLAB_KEY") == "" {
logger.Errorf("COMMENTO_GITLAB_KEY not configured, but COMMENTO_GITLAB_SECRET is set")
return errorOauthMisconfigured
}
if os.Getenv("GITLAB_SECRET") == "" {
logger.Errorf("COMMENTO_GITLAB_SECRET not configured, but COMMENTO_GITLAB_KEY is set")
return errorOauthMisconfigured
}
logger.Infof("loading gitlab OAuth config")
gitlabConfig = &oauth2.Config{
RedirectURL: os.Getenv("ORIGIN") + "/api/oauth/gitlab/callback",
ClientID: os.Getenv("GITLAB_KEY"),
ClientSecret: os.Getenv("GITLAB_SECRET"),
Scopes: []string{
"read_user",
},
Endpoint: gitlab.Endpoint,
}
gitlabConfig.Endpoint.AuthURL = os.Getenv("GITLAB_URL") + "/oauth/authorize"
gitlabConfig.Endpoint.TokenURL = os.Getenv("GITLAB_URL") + "/oauth/token"
gitlabConfigured = true
return nil
}

View File

@@ -0,0 +1,101 @@
package main
import (
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"io/ioutil"
"net/http"
"os"
)
func gitlabCallbackHandler(w http.ResponseWriter, r *http.Request) {
commenterToken := r.FormValue("state")
code := r.FormValue("code")
_, err := commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
token, err := gitlabConfig.Exchange(oauth2.NoContext, code)
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
resp, err := http.Get(os.Getenv("GITLAB_URL") + "/api/v4/user?access_token=" + token.AccessToken)
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
logger.Infof("%v", resp.StatusCode)
defer resp.Body.Close()
contents, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(w, "Error: %s", errorCannotReadResponse.Error())
return
}
user := make(map[string]interface{})
if err := json.Unmarshal(contents, &user); err != nil {
fmt.Fprintf(w, "Error: %s", errorInternal.Error())
return
}
if user["email"] == nil {
fmt.Fprintf(w, "Error: no email address returned by Gitlab")
return
}
email := user["email"].(string)
if user["name"] == nil {
fmt.Fprintf(w, "Error: no name returned by Gitlab")
return
}
name := user["name"].(string)
link := "undefined"
if user["web_url"] != nil {
link = user["web_url"].(string)
}
photo := "undefined"
if user["avatar_url"] != nil {
photo = user["avatar_url"].(string)
}
c, err := commenterGetByEmail("gitlab", email)
if err != nil && err != errorNoSuchCommenter {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
var commenterHex string
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "gitlab", "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "gitlab"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}
if err := commenterSessionUpdate(commenterToken, commenterHex); err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
}

View File

@@ -0,0 +1,25 @@
package main
import (
"fmt"
"net/http"
)
func gitlabRedirectHandler(w http.ResponseWriter, r *http.Request) {
if gitlabConfig == nil {
logger.Errorf("gitlab oauth access attempt without configuration")
fmt.Fprintf(w, "error: this website has not configured gitlab OAuth")
return
}
commenterToken := r.FormValue("commenterToken")
_, err := commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "error: %s\n", err.Error())
return
}
url := gitlabConfig.AuthCodeURL(commenterToken)
http.Redirect(w, r, url, http.StatusFound)
}

View File

@@ -37,7 +37,7 @@ func googleOauthConfigure() error {
Endpoint: google.Endpoint, Endpoint: google.Endpoint,
} }
configuredOauths = append(configuredOauths, "google") googleConfigured = true
return nil return nil
} }

View File

@@ -39,37 +39,45 @@ func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
c, err := commenterGetByEmail("google", user["email"].(string)) if user["email"] == nil {
fmt.Fprintf(w, "Error: no email address returned by Github")
return
}
email := user["email"].(string)
c, err := commenterGetByEmail("google", email)
if err != nil && err != errorNoSuchCommenter { if err != nil && err != errorNoSuchCommenter {
fmt.Fprintf(w, "Error: %s", err.Error()) fmt.Fprintf(w, "Error: %s", err.Error())
return return
} }
name := user["name"].(string)
link := "undefined"
if user["link"] != nil {
link = user["link"].(string)
}
photo := "undefined"
if user["picture"] != nil {
photo = user["picture"].(string)
}
var commenterHex string var commenterHex string
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter { if err == errorNoSuchCommenter {
var email string commenterHex, err = commenterNew(email, name, link, photo, "google", "")
if _, ok := user["email"]; ok {
email = user["email"].(string)
} else {
fmt.Fprintf(w, "Error: %s", errorInvalidEmail.Error())
return
}
var link string
if val, ok := user["link"]; ok {
link = val.(string)
} else {
link = "undefined"
}
commenterHex, err = commenterNew(email, user["name"].(string), link, user["picture"].(string), "google", "")
if err != nil { if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error()) fmt.Fprintf(w, "Error: %s", err.Error())
return return
} }
} else { } else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "google"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex commenterHex = c.CommenterHex
} }

61
api/oauth_sso.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"time"
)
type ssoPayload struct {
Domain string `json:"domain"`
Token string `json:"token"`
Email string `json:"email"`
Name string `json:"name"`
Link string `json:"link"`
Photo string `json:"photo"`
}
func ssoTokenNew(domain string, commenterToken string) (string, error) {
token, err := randomHex(32)
if err != nil {
logger.Errorf("error generating SSO token hex: %v", err)
return "", errorInternal
}
statement := `
INSERT INTO
ssoTokens (token, domain, commenterToken, creationDate)
VALUES ($1, $2, $3, $4 );
`
_, err = db.Exec(statement, token, domain, commenterToken, time.Now().UTC())
if err != nil {
logger.Errorf("error inserting SSO token: %v", err)
return "", errorInternal
}
return token, nil
}
func ssoTokenExtract(token string) (string, string, error) {
statement := `
SELECT domain, commenterToken
FROM ssoTokens
WHERE token = $1;
`
row := db.QueryRow(statement, token)
var domain string
var commenterToken string
if err := row.Scan(&domain, &commenterToken); err != nil {
return "", "", errorNoSuchToken
}
statement = `
DELETE FROM ssoTokens
WHERE token = $1;
`
if _, err := db.Exec(statement, token); err != nil {
logger.Errorf("cannot delete SSO token after usage: %v", err)
return "", "", errorInternal
}
return domain, commenterToken, nil
}

120
api/oauth_sso_callback.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
)
func ssoCallbackHandler(w http.ResponseWriter, r *http.Request) {
payloadHex := r.FormValue("payload")
signature := r.FormValue("hmac")
payloadBytes, err := hex.DecodeString(payloadHex)
if err != nil {
fmt.Fprintf(w, "Error: invalid JSON payload hex encoding: %s\n", err.Error())
return
}
signatureBytes, err := hex.DecodeString(signature)
if err != nil {
fmt.Fprintf(w, "Error: invalid HMAC signature hex encoding: %s\n", err.Error())
return
}
payload := ssoPayload{}
err = json.Unmarshal(payloadBytes, &payload)
if err != nil {
fmt.Fprintf(w, "Error: cannot unmarshal JSON payload: %s\n", err.Error())
return
}
if payload.Token == "" || payload.Email == "" || payload.Name == "" {
fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error())
return
}
if payload.Link == "" {
payload.Link = "undefined"
}
if payload.Photo == "" {
payload.Photo = "undefined"
}
domain, commenterToken, err := ssoTokenExtract(payload.Token)
if err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
d, err := domainGet(domain)
if err != nil {
if err == errorNoSuchDomain {
fmt.Fprintf(w, "Error: %s\n", err.Error())
} else {
logger.Errorf("cannot get domain for SSO: %v", err)
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
}
return
}
if d.SsoSecret == "" || d.SsoUrl == "" {
fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error())
return
}
key, err := hex.DecodeString(d.SsoSecret)
if err != nil {
logger.Errorf("cannot decode SSO secret as hex: %v", err)
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
h := hmac.New(sha256.New, key)
h.Write(payloadBytes)
expectedSignatureBytes := h.Sum(nil)
if !hmac.Equal(expectedSignatureBytes, signatureBytes) {
fmt.Fprintf(w, "Error: HMAC signature verification failed\n")
return
}
_, err = commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
c, err := commenterGetByEmail("sso:"+domain, payload.Email)
if err != nil && err != errorNoSuchCommenter {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
var commenterHex string
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(payload.Email, payload.Name, payload.Link, payload.Photo, "sso:"+domain, "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
if err = commenterUpdate(c.CommenterHex, payload.Email, payload.Name, payload.Link, payload.Photo, "sso:"+domain); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}
if err = commenterSessionUpdate(commenterToken, commenterHex); err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
}

88
api/oauth_sso_redirect.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/url"
)
func ssoRedirectHandler(w http.ResponseWriter, r *http.Request) {
commenterToken := r.FormValue("commenterToken")
domain := r.Header.Get("Referer")
if commenterToken == "" {
fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error())
return
}
domain = domainStrip(domain)
if domain == "" {
fmt.Fprintf(w, "Error: No Referer header found in request\n")
return
}
_, err := commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
d, err := domainGet(domain)
if err != nil {
fmt.Fprintf(w, "Error: %s\n", errorNoSuchDomain.Error())
return
}
if !d.SsoProvider {
fmt.Fprintf(w, "Error: SSO not configured for %s\n", domain)
return
}
if d.SsoSecret == "" || d.SsoUrl == "" {
fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error())
return
}
key, err := hex.DecodeString(d.SsoSecret)
if err != nil {
logger.Errorf("cannot decode SSO secret as hex: %v", err)
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
token, err := ssoTokenNew(domain, commenterToken)
if err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
tokenBytes, err := hex.DecodeString(token)
if err != nil {
logger.Errorf("cannot decode hex token: %v", err)
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
return
}
h := hmac.New(sha256.New, key)
h.Write(tokenBytes)
signature := hex.EncodeToString(h.Sum(nil))
u, err := url.Parse(d.SsoUrl)
if err != nil {
// this should really not be happening; we're checking if the
// passed URL is valid at domain update
logger.Errorf("cannot parse URL: %v", err)
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
return
}
q := u.Query()
q.Set("token", token)
q.Set("hmac", signature)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}

51
api/oauth_twitter.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"github.com/gomodule/oauth1/oauth"
"os"
"sync"
)
type twitterOauthState struct {
CommenterToken string
Cred *oauth.Credentials
}
var twitterClient *oauth.Client
var twitterCredMapLock sync.RWMutex
var twitterCredMap map[string]twitterOauthState
func twitterOauthConfigure() error {
twitterClient = nil
if os.Getenv("TWITTER_KEY") == "" && os.Getenv("TWITTER_SECRET") == "" {
return nil
}
if os.Getenv("TWITTER_KEY") == "" {
logger.Errorf("COMMENTO_TWITTER_KEY not configured, but COMMENTO_TWITTER_SECRET is set")
return errorOauthMisconfigured
}
if os.Getenv("TWITTER_SECRET") == "" {
logger.Errorf("COMMENTO_TWITTER_SECRET not configured, but COMMENTO_TWITTER_KEY is set")
return errorOauthMisconfigured
}
logger.Infof("loading twitter OAuth config")
twitterClient = &oauth.Client{
TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token",
ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authenticate",
TokenRequestURI: "https://api.twitter.com/oauth/access_token",
Credentials: oauth.Credentials{
Token: os.Getenv("TWITTER_KEY"),
Secret: os.Getenv("TWITTER_SECRET"),
},
}
twitterCredMap = make(map[string]twitterOauthState, 1e3)
twitterConfigured = true
return nil
}

View File

@@ -0,0 +1,135 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
)
func twitterCallbackHandler(w http.ResponseWriter, r *http.Request) {
token := r.FormValue("oauth_token")
verifier := r.FormValue("oauth_verifier")
twitterCredMapLock.RLock()
s, ok := twitterCredMap[token]
twitterCredMapLock.RUnlock()
commenterToken := s.CommenterToken
if !ok {
fmt.Fprintf(w, "no such token/verifier combination found")
return
}
_, err := commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
x, _, err := twitterClient.RequestToken(nil, s.Cred, verifier)
if err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
twitterCredMapLock.Lock()
delete(twitterCredMap, token)
twitterCredMapLock.Unlock()
resp, err := twitterClient.Get(nil, x, "https://api.twitter.com/1.1/account/verify_credentials.json", url.Values{"include_email": {"true"}})
if err != nil {
fmt.Fprintf(w, "Error getting email: %s\n", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
msg, _ := ioutil.ReadAll(resp.Body)
fmt.Fprintf(w, "Error: status %d: %s\n", resp.StatusCode, msg)
return
}
var res twitterOAuthReponse
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
if err := res.validate(); err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
email := res.Email
name := res.Name
link := res.getLinkURL()
photo := res.getImageURL()
c, err := commenterGetByEmail("twitter", email)
if err != nil && err != errorNoSuchCommenter {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
var commenterHex string
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "twitter", "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "twitter"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}
if err := commenterSessionUpdate(commenterToken, commenterHex); err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
}
// response from Twitter API.
// ref: https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/user-object
type twitterOAuthReponse struct {
Email string `json:"email"`
Name string `json:"name"`
ScreenName string `json:"screen_name"`
// normal image size is 48x48.
// ref: https://developer.twitter.com/en/docs/accounts-and-users/user-profile-images-and-banners
ImageURL string `json:"profile_image_url_https"`
}
func (r twitterOAuthReponse) validate() error {
if r.Email == "" {
return errors.New("no email address returned by Twitter")
}
if r.Name == "" {
return errors.New("no name returned by Twitter")
}
return nil
}
func (r twitterOAuthReponse) getLinkURL() string {
if r.ScreenName == "" {
return "undefined"
}
return fmt.Sprintf("https://twitter.com/%s", r.ScreenName)
}
func (r twitterOAuthReponse) getImageURL() string {
if r.ImageURL == "" {
return "undefined"
}
return r.ImageURL
}

View File

@@ -0,0 +1,39 @@
package main
import (
"fmt"
"net/http"
"os"
)
func twitterRedirectHandler(w http.ResponseWriter, r *http.Request) {
if twitterClient == nil {
logger.Errorf("twitter oauth access attempt without configuration")
fmt.Fprintf(w, "error: this website has not configured twitter OAuth")
return
}
commenterToken := r.FormValue("commenterToken")
_, err := commenterGetByCommenterToken(commenterToken)
if err != nil && err != errorNoSuchToken {
fmt.Fprintf(w, "error: %s\n", err.Error())
return
}
cred, err := twitterClient.RequestTemporaryCredentials(nil, os.Getenv("ORIGIN")+"/api/oauth/twitter/callback", nil)
if err != nil {
logger.Errorf("cannot get temporary twitter credentials: %v", err)
fmt.Fprintf(w, "error: %v", errorInternal.Error())
return
}
twitterCredMapLock.Lock()
twitterCredMap[cred.Token] = twitterOauthState{
CommenterToken: commenterToken,
Cred: cred,
}
twitterCredMapLock.Unlock()
http.Redirect(w, r, twitterClient.AuthorizationURL(cred, nil), http.StatusFound)
}

View File

@@ -11,9 +11,9 @@ func TestOwnerConfirmHexBasics(t *testing.T) {
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2") ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
statement := ` statement := `
UPDATE owners UPDATE owners
SET confirmedEmail=false; SET confirmedEmail=false;
` `
_, err := db.Exec(statement) _, err := db.Exec(statement)
if err != nil { if err != nil {
t.Errorf("unexpected error when setting confirmedEmail=false: %v", err) t.Errorf("unexpected error when setting confirmedEmail=false: %v", err)
@@ -23,10 +23,10 @@ func TestOwnerConfirmHexBasics(t *testing.T) {
confirmHex, _ := randomHex(32) confirmHex, _ := randomHex(32)
statement = ` statement = `
INSERT INTO INSERT INTO
ownerConfirmHexes (confirmHex, ownerHex, sendDate) ownerConfirmHexes (confirmHex, ownerHex, sendDate)
VALUES ($1, $2, $3 ); VALUES ($1, $2, $3 );
` `
_, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC()) _, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC())
if err != nil { if err != nil {
t.Errorf("unexpected error creating inserting confirmHex: %v\n", err) t.Errorf("unexpected error creating inserting confirmHex: %v\n", err)
@@ -39,10 +39,10 @@ func TestOwnerConfirmHexBasics(t *testing.T) {
} }
statement = ` statement = `
SELECT confirmedEmail SELECT confirmedEmail
FROM owners FROM owners
WHERE ownerHex=$1; WHERE ownerHex=$1;
` `
row := db.QueryRow(statement, ownerHex) row := db.QueryRow(statement, ownerHex)
var confirmedHex bool var confirmedHex bool

79
api/owner_delete.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"net/http"
)
func ownerDelete(ownerHex string, deleteDomains bool) error {
domains, err := domainList(ownerHex)
if err != nil {
return err
}
if len(domains) > 0 {
if !deleteDomains {
return errorCannotDeleteOwnerWithActiveDomains
}
for _, d := range domains {
if err := domainDelete(d.Domain); err != nil {
return err
}
}
}
statement := `
DELETE FROM owners
WHERE ownerHex = $1;
`
_, err = db.Exec(statement, ownerHex)
if err != nil {
return errorNoSuchOwner
}
statement = `
DELETE FROM ownersessions
WHERE ownerHex = $1;
`
_, err = db.Exec(statement, ownerHex)
if err != nil {
logger.Errorf("cannot delete from ownersessions: %v", err)
return errorInternal
}
statement = `
DELETE FROM resethexes
WHERE hex = $1;
`
_, err = db.Exec(statement, ownerHex)
if err != nil {
logger.Errorf("cannot delete from resethexes: %v", err)
return errorInternal
}
return nil
}
func ownerDeleteHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
OwnerToken *string `json:"ownerToken"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
o, err := ownerGetByOwnerToken(*x.OwnerToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err = ownerDelete(o.OwnerHex, false); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -2,20 +2,38 @@ package main
import () import ()
var ownersRowColumns string = `
owners.ownerHex,
owners.email,
owners.name,
owners.confirmedEmail,
owners.joinDate
`
func ownersRowScan(s sqlScanner, o *owner) error {
return s.Scan(
&o.OwnerHex,
&o.Email,
&o.Name,
&o.ConfirmedEmail,
&o.JoinDate,
)
}
func ownerGetByEmail(email string) (owner, error) { func ownerGetByEmail(email string) (owner, error) {
if email == "" { if email == "" {
return owner{}, errorMissingField return owner{}, errorMissingField
} }
statement := ` statement := `
SELECT ownerHex, email, name, confirmedEmail, joinDate SELECT ` + ownersRowColumns + `
FROM owners FROM owners
WHERE email=$1; WHERE email=$1;
` `
row := db.QueryRow(statement, email) row := db.QueryRow(statement, email)
var o owner var o owner
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil { if err := ownersRowScan(row, &o); err != nil {
// TODO: Make sure this is actually no such email. // TODO: Make sure this is actually no such email.
return owner{}, errorNoSuchEmail return owner{}, errorNoSuchEmail
} }
@@ -29,17 +47,38 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
} }
statement := ` statement := `
SELECT ownerHex, email, name, confirmedEmail, joinDate SELECT ` + ownersRowColumns + `
FROM owners FROM owners
WHERE email IN ( WHERE owners.ownerHex IN (
SELECT email FROM ownerSessions SELECT ownerSessions.ownerHex FROM ownerSessions
WHERE ownerToken = $1 WHERE ownerSessions.ownerToken = $1
); );
` `
row := db.QueryRow(statement, ownerToken) row := db.QueryRow(statement, ownerToken)
var o owner var o owner
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil { if err := ownersRowScan(row, &o); err != nil {
logger.Errorf("cannot scan owner: %v\n", err)
return owner{}, errorInternal
}
return o, nil
}
func ownerGetByOwnerHex(ownerHex string) (owner, error) {
if ownerHex == "" {
return owner{}, errorMissingField
}
statement := `
SELECT ` + ownersRowColumns + `
FROM owners
WHERE ownerHex = $1;
`
row := db.QueryRow(statement, ownerHex)
var o owner
if err := ownersRowScan(row, &o); err != nil {
logger.Errorf("cannot scan owner: %v\n", err) logger.Errorf("cannot scan owner: %v\n", err)
return owner{}, errorInternal return owner{}, errorInternal
} }

View File

@@ -16,6 +16,14 @@ func ownerNew(email string, name string, password string) (string, error) {
return "", errorNewOwnerForbidden return "", errorNewOwnerForbidden
} }
if _, err := ownerGetByEmail(email); err == nil {
return "", errorEmailAlreadyExists
}
if err := emailNew(email); err != nil {
return "", errorInternal
}
ownerHex, err := randomHex(32) ownerHex, err := randomHex(32)
if err != nil { if err != nil {
logger.Errorf("cannot generate ownerHex: %v", err) logger.Errorf("cannot generate ownerHex: %v", err)
@@ -48,10 +56,10 @@ func ownerNew(email string, name string, password string) (string, error) {
} }
statement = ` statement = `
INSERT INTO INSERT INTO
ownerConfirmHexes (confirmHex, ownerHex, sendDate) ownerConfirmHexes (confirmHex, ownerHex, sendDate)
VALUES ($1, $2, $3 ); VALUES ($1, $2, $3 );
` `
_, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC()) _, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC())
if err != nil { if err != nil {
logger.Errorf("cannot insert confirmHex: %v\n", err) logger.Errorf("cannot insert confirmHex: %v\n", err)
@@ -84,10 +92,8 @@ func ownerNewHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := commenterNew(*x.Email, *x.Name, "undefined", "undefined", "commento", *x.Password); err != nil { // Errors in creating a commenter account should not hold this up.
bodyMarshal(w, response{"success": false, "message": err.Error()}) _, _ = commenterNew(*x.Email, *x.Name, "undefined", "undefined", "commento", *x.Password)
return
}
bodyMarshal(w, response{"success": true, "confirmEmail": smtpConfigured}) bodyMarshal(w, response{"success": true, "confirmEmail": smtpConfigured})
} }

View File

@@ -1,70 +0,0 @@
package main
import (
"net/http"
"time"
)
func ownerSendResetHex(email string) error {
if email == "" {
return errorMissingField
}
if !smtpConfigured {
return errorSmtpNotConfigured
}
o, err := ownerGetByEmail(email)
if err != nil {
if err == errorNoSuchEmail {
// TODO: use a more random time instead.
time.Sleep(1 * time.Second)
return nil
} else {
logger.Errorf("cannot get owner by email: %v", err)
return errorInternal
}
}
resetHex, err := randomHex(32)
if err != nil {
return err
}
statement := `
INSERT INTO
ownerResetHexes (resetHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
_, err = db.Exec(statement, resetHex, o.OwnerHex, time.Now().UTC())
if err != nil {
logger.Errorf("cannot insert resetHex: %v", err)
return errorInternal
}
err = smtpOwnerResetHex(email, o.Name, resetHex)
if err != nil {
return err
}
return nil
}
func ownerSendResetHexHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
Email *string `json:"email"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err := ownerSendResetHex(*x.Email); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -1,72 +0,0 @@
package main
import (
"golang.org/x/crypto/bcrypt"
"net/http"
)
func ownerResetPassword(resetHex string, password string) error {
if resetHex == "" || password == "" {
return errorMissingField
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logger.Errorf("cannot generate hash from password: %v\n", err)
return errorInternal
}
statement := `
UPDATE owners SET passwordHash=$1
WHERE email IN (
SELECT email FROM ownerResetHexes
WHERE resetHex=$2
);
`
res, err := db.Exec(statement, string(passwordHash), resetHex)
if err != nil {
logger.Errorf("cannot change user's password: %v\n", err)
return errorInternal
}
count, err := res.RowsAffected()
if err != nil {
logger.Errorf("cannot count rows affected: %v\n", err)
return errorInternal
}
if count == 0 {
return errorNoSuchResetToken
}
statement = `
DELETE FROM ownerResetHexes
WHERE resetHex=$1;
`
_, err = db.Exec(statement, resetHex)
if err != nil {
logger.Warningf("cannot remove reset token: %v\n", err)
}
return nil
}
func ownerResetPasswordHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
ResetHex *string `json:"resetHex"`
Password *string `json:"password"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err := ownerResetPassword(*x.ResetHex, *x.Password); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -1,40 +0,0 @@
package main
import (
"testing"
"time"
)
func TestOwnerResetPasswordBasics(t *testing.T) {
failTestOnError(t, setupTestEnv())
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
resetHex, _ := randomHex(32)
statement := `
INSERT INTO
ownerResetHexes (resetHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
_, err := db.Exec(statement, resetHex, ownerHex, time.Now().UTC())
if err != nil {
t.Errorf("unexpected error inserting resetHex: %v", err)
return
}
if err = ownerResetPassword(resetHex, "hunter3"); err != nil {
t.Errorf("unexpected error resetting password: %v", err)
return
}
if _, err := ownerLogin("test@example.com", "hunter2"); err == nil {
t.Errorf("expected error not found when given old password")
return
}
if _, err := ownerLogin("test@example.com", "hunter3"); err != nil {
t.Errorf("unexpected error when logging in: %v", err)
return
}
}

View File

@@ -3,8 +3,10 @@ package main
import () import ()
type page struct { type page struct {
Domain string `json:"domain"` Domain string `json:"domain"`
Path string `json:"path"` Path string `json:"path"`
IsLocked bool `json:"isLocked"` IsLocked bool `json:"isLocked"`
CommentCount int `json:"commentCount"` CommentCount int `json:"commentCount"`
StickyCommentHex string `json:"stickyCommentHex"`
Title string `json:"title"`
} }

View File

@@ -11,20 +11,22 @@ func pageGet(domain string, path string) (page, error) {
} }
statement := ` statement := `
SELECT isLocked, commentCount SELECT isLocked, commentCount, stickyCommentHex, title
FROM pages FROM pages
WHERE domain=$1 AND path=$2; WHERE domain=$1 AND path=$2;
` `
row := db.QueryRow(statement, domain, path) row := db.QueryRow(statement, domain, path)
p := page{Domain: domain, Path: path} p := page{Domain: domain, Path: path}
if err := row.Scan(&p.IsLocked, &p.CommentCount); err != nil { if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex, &p.Title); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// If there haven't been any comments, there won't be a record for this // If there haven't been any comments, there won't be a record for this
// page. The sane thing to do is return defaults. // page. The sane thing to do is return defaults.
// TODO: the defaults are hard-coded in two places: here and the schema // TODO: the defaults are hard-coded in two places: here and the schema
p.IsLocked = false p.IsLocked = false
p.CommentCount = 0 p.CommentCount = 0
p.StickyCommentHex = "none"
p.Title = ""
} else { } else {
logger.Errorf("error scanning page: %v", err) logger.Errorf("error scanning page: %v", err)
return page{}, errorInternal return page{}, errorInternal

28
api/page_title.go Normal file
View File

@@ -0,0 +1,28 @@
package main
import ()
func pageTitleUpdate(domain string, path string) (string, error) {
title, err := htmlTitleGet("http://" + domain + path)
if err != nil {
// This could fail due to a variety of reasons that we can't control such
// as the user's URL 404 or something, so let's not pollute the error log
// with messages. Just use a sane title. Maybe we'll have the ability to
// retry later.
logger.Errorf("%v", err)
title = domain
}
statement := `
UPDATE pages
SET title = $3
WHERE domain = $1 AND path = $2;
`
_, err = db.Exec(statement, domain, path, title)
if err != nil {
logger.Errorf("cannot update pages table with title: %v", err)
return "", err
}
return title, nil
}

View File

@@ -13,12 +13,12 @@ func pageUpdate(p page) error {
// commentCount // commentCount
statement := ` statement := `
INSERT INTO INSERT INTO
pages (domain, path, isLocked) pages (domain, path, isLocked, stickyCommentHex)
VALUES ($1, $2, $3 ) VALUES ($1, $2, $3, $4 )
ON CONFLICT (domain, path) DO ON CONFLICT (domain, path) DO
UPDATE SET isLocked = $3; UPDATE SET isLocked = $3, stickyCommentHex = $4;
` `
_, err := db.Exec(statement, p.Domain, p.Path, p.IsLocked) _, err := db.Exec(statement, p.Domain, p.Path, p.IsLocked, p.StickyCommentHex)
if err != nil { if err != nil {
logger.Errorf("error setting page attributes: %v", err) logger.Errorf("error setting page attributes: %v", err)
return errorInternal return errorInternal

82
api/reset.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"golang.org/x/crypto/bcrypt"
"net/http"
)
func reset(resetHex string, password string) (string, error) {
if resetHex == "" || password == "" {
return "", errorMissingField
}
statement := `
SELECT hex, entity
FROM resetHexes
WHERE resetHex = $1;
`
row := db.QueryRow(statement, resetHex)
var hex string
var entity string
if err := row.Scan(&hex, &entity); err != nil {
// TODO: is this the only error?
return "", errorNoSuchResetToken
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logger.Errorf("cannot generate hash from password: %v\n", err)
return "", errorInternal
}
if entity == "owner" {
statement = `
UPDATE owners SET passwordHash = $1
WHERE ownerHex = $2;
`
} else {
statement = `
UPDATE commenters SET passwordHash = $1
WHERE commenterHex = $2;
`
}
_, err = db.Exec(statement, string(passwordHash), hex)
if err != nil {
logger.Errorf("cannot change %s's password: %v\n", entity, err)
return "", errorInternal
}
statement = `
DELETE FROM resetHexes
WHERE resetHex = $1;
`
_, err = db.Exec(statement, resetHex)
if err != nil {
logger.Warningf("cannot remove resetHex: %v\n", err)
}
return entity, nil
}
func resetHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
ResetHex *string `json:"resetHex"`
Password *string `json:"password"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
entity, err := reset(*x.ResetHex, *x.Password)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "entity": entity})
}

View File

@@ -8,28 +8,54 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST") router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST")
router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET") router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET")
router.HandleFunc("/api/owner/login", ownerLoginHandler).Methods("POST") router.HandleFunc("/api/owner/login", ownerLoginHandler).Methods("POST")
router.HandleFunc("/api/owner/send-reset-hex", ownerSendResetHexHandler).Methods("POST")
router.HandleFunc("/api/owner/reset-password", ownerResetPasswordHandler).Methods("POST")
router.HandleFunc("/api/owner/self", ownerSelfHandler).Methods("POST") router.HandleFunc("/api/owner/self", ownerSelfHandler).Methods("POST")
router.HandleFunc("/api/owner/delete", ownerDeleteHandler).Methods("POST")
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST") router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST") router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST")
router.HandleFunc("/api/domain/clear", domainClearHandler).Methods("POST")
router.HandleFunc("/api/domain/sso/new", domainSsoSecretNewHandler).Methods("POST")
router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST") router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST")
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST") router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST") router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST") router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST") router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST")
router.HandleFunc("/api/domain/import/disqus", domainImportDisqusHandler).Methods("POST") router.HandleFunc("/api/domain/import/disqus", domainImportDisqusHandler).Methods("POST")
router.HandleFunc("/api/domain/import/commento", domainImportCommentoHandler).Methods("POST")
router.HandleFunc("/api/domain/export/begin", domainExportBeginHandler).Methods("POST")
router.HandleFunc("/api/domain/export/download", domainExportDownloadHandler).Methods("GET")
router.HandleFunc("/api/commenter/token/new", commenterTokenNewHandler).Methods("GET") router.HandleFunc("/api/commenter/token/new", commenterTokenNewHandler).Methods("GET")
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST") router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST") router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST") router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
router.HandleFunc("/api/commenter/update", commenterUpdateHandler).Methods("POST")
router.HandleFunc("/api/commenter/photo", commenterPhotoHandler).Methods("GET")
router.HandleFunc("/api/forgot", forgotHandler).Methods("POST")
router.HandleFunc("/api/reset", resetHandler).Methods("POST")
router.HandleFunc("/api/email/get", emailGetHandler).Methods("POST")
router.HandleFunc("/api/email/update", emailUpdateHandler).Methods("POST")
router.HandleFunc("/api/email/moderate", emailModerateHandler).Methods("GET")
router.HandleFunc("/api/oauth/google/redirect", googleRedirectHandler).Methods("GET") router.HandleFunc("/api/oauth/google/redirect", googleRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET") router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET")
router.HandleFunc("/api/oauth/github/redirect", githubRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/github/callback", githubCallbackHandler).Methods("GET")
router.HandleFunc("/api/oauth/twitter/redirect", twitterRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/twitter/callback", twitterCallbackHandler).Methods("GET")
router.HandleFunc("/api/oauth/gitlab/redirect", gitlabRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/gitlab/callback", gitlabCallbackHandler).Methods("GET")
router.HandleFunc("/api/oauth/sso/redirect", ssoRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/sso/callback", ssoCallbackHandler).Methods("GET")
router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST") router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST")
router.HandleFunc("/api/comment/edit", commentEditHandler).Methods("POST")
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST") router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST") router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")
router.HandleFunc("/api/comment/vote", commentVoteHandler).Methods("POST") router.HandleFunc("/api/comment/vote", commentVoteHandler).Methods("POST")

View File

@@ -1,41 +1,82 @@
package main package main
import ( import (
"bytes"
"fmt"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"html/template"
"io/ioutil" "io/ioutil"
"mime" "mime"
"net/http" "net/http"
"os" "os"
"path" "path"
"strings"
) )
func redirectLogin(w http.ResponseWriter, r *http.Request) { func redirectLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, os.Getenv("ORIGIN")+"/login", 301) http.Redirect(w, r, os.Getenv("ORIGIN")+"/login", 301)
} }
type staticAssetPlugs struct { type staticPlugs struct {
Origin string
}
type staticHtmlPlugs struct {
Origin string Origin string
CdnPrefix string CdnPrefix string
Footer template.HTML Footer string
}
var asset map[string][]byte = make(map[string][]byte)
var contentType map[string]string = make(map[string]string)
var footer string
var compress bool
func fileDetemplate(f string) ([]byte, error) {
contents, err := ioutil.ReadFile(f)
if err != nil {
logger.Errorf("cannot read file %s: %v", f, err)
return []byte{}, err
}
x := string(contents)
x = strings.Replace(x, "[[[.Origin]]]", os.Getenv("ORIGIN"), -1)
x = strings.Replace(x, "[[[.CdnPrefix]]]", os.Getenv("CDN_PREFIX"), -1)
x = strings.Replace(x, "[[[.Footer]]]", footer, -1)
x = strings.Replace(x, "[[[.Version]]]", version, -1)
return []byte(x), nil
}
func footerInit() error {
contents, err := fileDetemplate(os.Getenv("STATIC") + "/footer.html")
if err != nil {
logger.Errorf("cannot init footer: %v", err)
return err
}
footer = string(contents)
return nil
}
func fileLoad(f string) ([]byte, error) {
b, err := fileDetemplate(f)
if err != nil {
logger.Errorf("cannot load file %s: %v", f, err)
return []byte{}, err
}
if !compress {
return b, nil
}
return gzipStatic(b)
} }
func staticRouterInit(router *mux.Router) error { func staticRouterInit(router *mux.Router) error {
var err error
subdir := pathStrip(os.Getenv("ORIGIN")) subdir := pathStrip(os.Getenv("ORIGIN"))
asset := make(map[string][]byte) if err = footerInit(); err != nil {
gzippedAsset := make(map[string][]byte) logger.Errorf("error initialising static router: %v", err)
return err
for _, dir := range []string{"js", "css", "images"} { }
sl := string(os.PathSeparator)
dir = sl + dir
for _, dir := range []string{"/js", "/css", "/images", "/fonts"} {
files, err := ioutil.ReadDir(os.Getenv("STATIC") + dir) files, err := ioutil.ReadDir(os.Getenv("STATIC") + dir)
if err != nil { if err != nil {
logger.Errorf("cannot read directory %s%s: %v", os.Getenv("STATIC"), dir, err) logger.Errorf("cannot read directory %s%s: %v", os.Getenv("STATIC"), dir, err)
@@ -43,108 +84,50 @@ func staticRouterInit(router *mux.Router) error {
} }
for _, file := range files { for _, file := range files {
p := dir + sl + file.Name() f := dir + "/" + file.Name()
asset[subdir+f], err = fileLoad(os.Getenv("STATIC") + f)
contents, err := ioutil.ReadFile(os.Getenv("STATIC") + p)
if err != nil { if err != nil {
logger.Errorf("cannot read file %s%s: %v", os.Getenv("STATIC"), p, err) logger.Errorf("cannot detemplate %s%s: %v", os.Getenv("STATIC"), f, err)
return err return err
} }
prefix := ""
if dir == "/js" {
prefix = "window.commentoOrigin='" + os.Getenv("ORIGIN") + "';\n"
prefix += "window.commentoCdn='" + os.Getenv("CDN_PREFIX") + "';\n"
}
gzip := (os.Getenv("GZIP_STATIC") == "true")
asset[subdir+p] = []byte(prefix + string(contents))
if gzip {
gzippedAsset[subdir+p], err = gzipStatic(asset[subdir+p])
if err != nil {
logger.Errorf("error gzipping %s: %v", p, err)
return err
}
}
// faster than checking inside the handler
if !gzip {
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
w.Write(asset[r.URL.Path])
})
} else {
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
w.Header().Set("Content-Encoding", "gzip")
w.Write(gzippedAsset[r.URL.Path])
})
}
} }
} }
footer, err := ioutil.ReadFile(os.Getenv("STATIC") + string(os.PathSeparator) + "footer.html")
if err != nil {
logger.Errorf("cannot read file footer.html: %v", err)
return err
}
pages := []string{ pages := []string{
"login", "/login",
"forgot", "/forgot",
"reset-password", "/reset",
"signup", "/signup",
"confirm-email", "/confirm-email",
"dashboard", "/unsubscribe",
"logout", "/dashboard",
} "/settings",
"/logout",
html := make(map[string]string) "/profile",
for _, page := range pages {
html[subdir+page] = ""
} }
for _, page := range pages { for _, page := range pages {
sl := string(os.PathSeparator) f := page + ".html"
page = sl + page asset[subdir+page], err = fileLoad(os.Getenv("STATIC") + f)
file := page + ".html"
contents, err := ioutil.ReadFile(os.Getenv("STATIC") + file)
if err != nil { if err != nil {
logger.Errorf("cannot read file %s%s: %v", os.Getenv("STATIC"), file, err) logger.Errorf("cannot detemplate %s%s: %v", os.Getenv("STATIC"), f, err)
return err return err
} }
result := string(contents)
for {
t, err := template.New(page).Delims("[[[", "]]]").Parse(result)
if err != nil {
logger.Errorf("cannot parse %s%s template: %v", os.Getenv("STATIC"), file, err)
return err
}
var buf bytes.Buffer
t.Execute(&buf, &staticHtmlPlugs{
Origin: os.Getenv("ORIGIN"),
CdnPrefix: os.Getenv("CDN_PREFIX"),
Footer: template.HTML(string(footer)),
})
result = buf.String()
if result == html[subdir+page] {
break
} else {
html[subdir+page] = result
continue
}
}
} }
for _, page := range pages { for p, _ := range asset {
router.HandleFunc("/"+page, func(w http.ResponseWriter, r *http.Request) { if path.Ext(p) != "" {
fmt.Fprint(w, html[r.URL.Path]) contentType[p] = mime.TypeByExtension(path.Ext(p))
} else {
contentType[p] = "text/html; charset=utf-8"
}
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType[r.URL.Path])
if compress {
w.Header().Set("Content-Encoding", "gzip")
}
w.Write(asset[r.URL.Path])
}) })
} }

View File

@@ -7,7 +7,14 @@ import (
) )
func sigintCleanup() int { func sigintCleanup() int {
// TODO: close the database connection and do other cleanup jobs if db != nil {
err := db.Close()
if err == nil {
logger.Errorf("cannot close database connection: %v", err)
return 1
}
}
return 0 return 0
} }

View File

@@ -13,7 +13,7 @@ func smtpConfigure() error {
password := os.Getenv("SMTP_PASSWORD") password := os.Getenv("SMTP_PASSWORD")
host := os.Getenv("SMTP_HOST") host := os.Getenv("SMTP_HOST")
port := os.Getenv("SMTP_PORT") port := os.Getenv("SMTP_PORT")
if username == "" || password == "" || host == "" || port == "" { if host == "" || port == "" {
logger.Warningf("smtp not configured, no emails will be sent") logger.Warningf("smtp not configured, no emails will be sent")
smtpConfigured = false smtpConfigured = false
return nil return nil
@@ -26,7 +26,11 @@ func smtpConfigure() error {
} }
logger.Infof("configuring smtp: %s", host) logger.Infof("configuring smtp: %s", host)
smtpAuth = smtp.PlainAuth("", username, password, host) if username == "" || password == "" {
logger.Warningf("no SMTP username/password set, Commento will assume they aren't required")
} else {
smtpAuth = smtp.PlainAuth("", username, password, host)
}
smtpConfigured = true smtpConfigured = true
return nil return nil
} }

29
api/smtp_domain_export.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"bytes"
"net/smtp"
"os"
)
type domainExportPlugs struct {
Origin string
Domain string
ExportHex string
}
func smtpDomainExport(to string, toName string, domain string, exportHex string) error {
var header bytes.Buffer
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Commento Data Export"})
var body bytes.Buffer
templates["domain-export"].Execute(&body, &domainExportPlugs{Origin: os.Getenv("ORIGIN"), ExportHex: exportHex})
err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
if err != nil {
logger.Errorf("cannot send data export email: %v", err)
return errorCannotSendEmail
}
return nil
}

View File

@@ -0,0 +1,28 @@
package main
import (
"bytes"
"net/smtp"
"os"
)
type domainExportErrorPlugs struct {
Origin string
Domain string
}
func smtpDomainExportError(to string, toName string, domain string) error {
var header bytes.Buffer
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Commento Data Export"})
var body bytes.Buffer
templates["data-export-error"].Execute(&body, &domainExportPlugs{Origin: os.Getenv("ORIGIN")})
err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
if err != nil {
logger.Errorf("cannot send data export error email: %v", err)
return errorCannotSendEmail
}
return nil
}

View File

@@ -0,0 +1,65 @@
package main
import (
"bytes"
"fmt"
ht "html/template"
"net/smtp"
"os"
tt "text/template"
)
type emailNotificationPlugs struct {
Origin string
Kind string
UnsubscribeSecretHex string
Domain string
Path string
CommentHex string
CommenterName string
Title string
Html ht.HTML
}
func smtpEmailNotification(to string, toName string, kind string, domain string, path string, commentHex string, commenterName string, title string, html string, unsubscribeSecretHex string) error {
h, err := tt.New("header").Parse(`MIME-Version: 1.0
From: Commento <{{.FromAddress}}>
To: {{.ToName}} <{{.ToAddress}}>
Content-Type: text/html; charset=UTF-8
Subject: {{.Subject}}
`)
var header bytes.Buffer
h.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "[Commento] " + title})
t, err := ht.ParseFiles(fmt.Sprintf("%s/templates/email-notification.txt", os.Getenv("STATIC")))
if err != nil {
logger.Errorf("cannot parse %s/templates/email-notification.txt: %v", os.Getenv("STATIC"), err)
return errorMalformedTemplate
}
var body bytes.Buffer
err = t.Execute(&body, &emailNotificationPlugs{
Origin: os.Getenv("ORIGIN"),
Kind: kind,
Domain: domain,
Path: path,
CommentHex: commentHex,
CommenterName: commenterName,
Title: title,
Html: ht.HTML(html),
UnsubscribeSecretHex: unsubscribeSecretHex,
})
if err != nil {
logger.Errorf("error generating templated HTML for email notification: %v", err)
return err
}
err = smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
if err != nil {
logger.Errorf("cannot send email notification: %v", err)
return errorCannotSendEmail
}
return nil
}

View File

@@ -6,17 +6,17 @@ import (
"os" "os"
) )
type ownerResetHexPlugs struct { type resetHexPlugs struct {
Origin string Origin string
ResetHex string ResetHex string
} }
func smtpOwnerResetHex(to string, toName string, resetHex string) error { func smtpResetHex(to string, toName string, resetHex string) error {
var header bytes.Buffer var header bytes.Buffer
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"}) headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"})
var body bytes.Buffer var body bytes.Buffer
templates["reset-hex"].Execute(&body, &ownerResetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex}) templates["reset-hex"].Execute(&body, &resetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex})
err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body)) err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
if err != nil { if err != nil {

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