134 Commits

Author SHA1 Message Date
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
159 changed files with 4791 additions and 1332 deletions

View File

@@ -50,8 +50,8 @@ aws-upload-tags:
- export PATH=$PATH:/go/bin
- cd /go/src/$CI_PROJECT_NAME
- make prod
- cd build/prod && tar -zcvf /commento-linux-amd64-$(git describe --tags).tgz .
- aws s3 cp /commento-linux-amd64-$(git describe --tags).tgz s3://commento-release/
- cd build/prod && tar -zcvf /commento-linux-amd64-$(git describe --tags).tar.gz .
- aws s3 cp /commento-linux-amd64-$(git describe --tags).tar.gz s3://commento-release/
build-docker:
stage: build-docker

View File

@@ -52,6 +52,7 @@ COPY --from=frontend-build /commento/frontend/build/prod/*.html /commento/
COPY --from=frontend-build /commento/frontend/build/prod/css/*.css /commento/css/
COPY --from=frontend-build /commento/frontend/build/prod/js/*.js /commento/js/
COPY --from=frontend-build /commento/frontend/build/prod/images/* /commento/images/
COPY --from=frontend-build /commento/frontend/build/prod/fonts/* /commento/fonts/
COPY --from=templates-build /commento/templates/build/prod/templates/ /commento/templates/
COPY --from=db-build /commento/db/build/prod/db/ /commento/db/

16
api/Gopkg.lock generated
View File

@@ -25,6 +25,14 @@
revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
version = "v1.1.0"
[[projects]]
branch = "master"
digest = "1:d03d0fae6a7a80e89c540787a69ab6e0d3b773fdb3303c0b3d96a15490c6ef32"
name = "github.com/gomodule/oauth1"
packages = ["oauth"]
pruneopts = "UT"
revision = "9a59ed3b0a84f454c260f2f8f82918223fc5630f"
[[projects]]
digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1"
name = "github.com/gorilla/context"
@@ -116,10 +124,12 @@
[[projects]]
branch = "master"
digest = "1:bea0314c10bd362ab623af4880d853b5bad3b63d0ab9945c47e461b8d04203ed"
digest = "1:341ceeee37101c62dae441691406bf4ecc71bbeb7b424417879fe88d9f88f487"
name = "golang.org/x/oauth2"
packages = [
".",
"github",
"gitlab",
"google",
"internal",
"jws",
@@ -152,6 +162,7 @@
analyzer-version = 1
input-imports = [
"github.com/adtac/go-akismet/akismet",
"github.com/gomodule/oauth1/oauth",
"github.com/gorilla/handlers",
"github.com/gorilla/mux",
"github.com/lib/pq",
@@ -160,7 +171,10 @@
"github.com/op/go-logging",
"github.com/russross/blackfriday",
"golang.org/x/crypto/bcrypt",
"golang.org/x/net/html",
"golang.org/x/oauth2",
"golang.org/x/oauth2/github",
"golang.org/x/oauth2/gitlab",
"golang.org/x/oauth2/google",
]
solver-name = "gps-cdcl"

View File

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

View File

@@ -1,27 +1,51 @@
package main
import (
"github.com/lib/pq"
"net/http"
)
func commentCount(domain string, path string) (int, error) {
// path can be empty
func commentCount(domain string, paths []string) (map[string]int, error) {
commentCounts := map[string]int{}
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 {
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) {
type request struct {
Domain *string `json:"domain"`
Path *string `json:"path"`
Domain *string `json:"domain"`
Paths *[]string `json:"paths"`
}
var x request
@@ -31,13 +55,12 @@ func commentCountHandler(w http.ResponseWriter, r *http.Request) {
}
domain := domainStrip(*x.Domain)
path := *x.Path
count, err := commentCount(domain, path)
commentCounts, err := commentCount(domain, *x.Paths)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
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", "**baz**", "unapproved", time.Now().UTC())
count, err := commentCount("example.com", "/path.html")
counts, err := commentCount("example.com", []string{"/path.html"})
if err != nil {
t.Errorf("unexpected error counting comments: %v", err)
return
}
if count != 2 {
t.Errorf("expected count=2 got count=%d", count)
if counts["/path.html"] != 2 {
t.Errorf("expected count=2 got count=%d", counts["/path.html"])
return
}
}
@@ -29,25 +29,25 @@ func TestCommentCountBasics(t *testing.T) {
func TestCommentCountNewPage(t *testing.T) {
failTestOnError(t, setupTestEnv())
count, err := commentCount("example.com", "/path.html")
counts, err := commentCount("example.com", []string{"/path.html"})
if err != nil {
t.Errorf("unexpected error counting comments: %v", err)
return
}
if count != 0 {
t.Errorf("expected count=0 got count=%d", count)
if counts["/path.html"] != 0 {
t.Errorf("expected count=0 got count=%d", counts["/path.html"])
return
}
}
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)
return
}
if _, err := commentCount("", ""); err == nil {
if _, err := commentCount("", []string{""}); err == nil {
t.Errorf("expected error not found counting comments with empty everything")
return
}

View File

@@ -12,22 +12,26 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
}
statement := `
SELECT commentHex, commenterHex, markdown, html, parentHex, score, state, creationDate
SELECT
commentHex,
commenterHex,
markdown,
html,
parentHex,
score,
state,
creationDate
FROM comments
WHERE
comments.domain = $1 AND
comments.path = $2
`
comments.domain = $1 AND
comments.path = $2
`
if !includeUnapproved {
if commenterHex == "anonymous" {
statement += `
AND state = 'approved'
`
statement += `AND state = 'approved'`
} else {
statement += `
AND (state = 'approved' OR commenterHex = $3)
`
statement += `AND (state = 'approved' OR commenterHex = $3)`
}
}
@@ -54,16 +58,24 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
comments := []comment{}
for rows.Next() {
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.CreationDate); err != nil {
return nil, nil, errorInternal
}
if commenterHex != "anonymous" {
statement = `
SELECT direction
FROM votes
WHERE commentHex=$1 AND commenterHex=$2;
`
SELECT direction
FROM votes
WHERE commentHex=$1 AND commenterHex=$2;
`
row := db.QueryRow(statement, c.CommentHex, commenterHex)
if err = row.Scan(&c.Direction); err != nil {
@@ -120,6 +132,8 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
commenterHex := "anonymous"
isModerator := false
modList := map[string]bool{}
if *x.CommenterToken != "anonymous" {
c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil {
@@ -134,11 +148,15 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
}
for _, mod := range d.Moderators {
modList[mod.Email] = true
if mod.Email == c.Email {
isModerator = true
break
}
}
} else {
for _, mod := range d.Moderators {
modList[mod.Email] = true
}
}
domainViewRecord(domain, commenterHex)
@@ -149,16 +167,32 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
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{
"success": true,
"domain": domain,
"comments": comments,
"commenters": commenters,
"commenters": _commenters,
"requireModeration": d.RequireModeration,
"requireIdentification": d.RequireIdentification,
"isFrozen": d.State == "frozen",
"isModerator": isModerator,
"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

@@ -77,6 +77,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
return
}
if d.RequireIdentification && *x.CommenterToken == "anonymous" {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
// logic: (empty column indicates the value doesn't matter)
// | anonymous | moderator | requireIdentification | requireModeration | moderateAllAnonymous | approved? |
// |-----------+-----------+-----------------------+-------------------+----------------------+-----------|
@@ -94,7 +99,7 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
if isSpam(*x.Domain, getIp(r), getUserAgent(r), "Anonymous", "", "", *x.Markdown) {
state = "flagged"
} else {
if d.ModerateAllAnonymous {
if d.ModerateAllAnonymous || d.RequireModeration {
state = "unapproved"
} else {
state = "approved"
@@ -139,5 +144,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
return
}
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state})
// 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, *x.ParentHex, state)
}
}

View File

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

View File

@@ -66,5 +66,6 @@ func commenterGetByCommenterToken(commenterToken string) (commenter, error) {
return commenter{}, errorNoSuchToken
}
// TODO: use a join instead of two queries?
return commenterGetByHex(commenterHex)
}

View File

@@ -67,5 +67,12 @@ func commenterLoginHandler(w http.ResponseWriter, r *http.Request) {
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
}
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken, "commenter": c})
}

View File

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

34
api/commenter_photo.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"io"
"net/http"
)
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" {
url += "?sz=50"
} else if c.Provider == "github" {
url += "&s=50"
} else if c.Provider == "twitter" {
url += "?size=normal"
} else if c.Provider == "gitlab" {
url += "?width=50"
}
resp, err := http.Get(url)
if err != nil {
http.NotFound(w, r)
return
}
defer resp.Body.Close()
io.Copy(w, resp.Body)
}

View File

@@ -21,5 +21,11 @@ func commenterSelfHandler(w http.ResponseWriter, r *http.Request) {
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

@@ -47,6 +47,15 @@ func configParse() error {
"GOOGLE_KEY": "",
"GOOGLE_SECRET": "",
"GITHUB_KEY": "",
"GITHUB_SECRET": "",
"TWITTER_KEY": "",
"TWITTER_SECRET": "",
"GITLAB_KEY": "",
"GITLAB_SECRET": "",
}
for key, value := range defaults {

View File

@@ -1,3 +1,3 @@
package main
var version = "v1.4.2"
var version = "v1.7.0"

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
}

View File

@@ -0,0 +1,66 @@
package main
import (
"time"
)
func emailNotificationBegin() error {
go func() {
for {
statement := `
SELECT email, sendModeratorNotifications, sendReplyNotifications
FROM emails
WHERE pendingEmails > 0 AND lastEmailNotificationDate < $1;
`
rows, err := db.Query(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute))
if err != nil {
logger.Errorf("cannot query domains: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var email string
var sendModeratorNotifications bool
var sendReplyNotifications bool
if err = rows.Scan(&email, &sendModeratorNotifications, &sendReplyNotifications); err != nil {
logger.Errorf("cannot scan email in cron job to send notifications: %v", err)
continue
}
if _, ok := emailQueue[email]; !ok {
if err = emailNotificationPendingReset(email); err != nil {
logger.Errorf("error resetting pendingEmails: %v", err)
continue
}
}
cont := true
kindListMap := map[string][]emailNotification{}
for cont {
select {
case e := <-emailQueue[email]:
if _, ok := kindListMap[e.Kind]; !ok {
kindListMap[e.Kind] = []emailNotification{}
}
if (e.Kind == "reply" && sendReplyNotifications) || sendModeratorNotifications {
kindListMap[e.Kind] = append(kindListMap[e.Kind], e)
}
default:
cont = false
break
}
}
for kind, list := range kindListMap {
go emailNotificationSend(email, kind, list)
}
}
time.Sleep(10 * time.Minute)
}
}()
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

@@ -6,6 +6,10 @@ import (
"strings"
)
var goMigrations = map[string](func() error){
"20190213033530-email-notifications.sql": migrateEmails,
}
func migrate() error {
return migrateFromDir(os.Getenv("STATIC") + "/db")
}
@@ -69,6 +73,13 @@ func migrateFromDir(dir string) error {
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++
}
}

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,15 +5,24 @@ import (
)
type domain struct {
Domain string `json:"domain"`
OwnerHex string `json:"ownerHex"`
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
State string `json:"state"`
ImportedComments bool `json:"importedComments"`
AutoSpamFilter bool `json:"autoSpamFilter"`
RequireModeration bool `json:"requireModeration"`
RequireIdentification bool `json:"requireIdentification"`
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
Moderators []moderator `json:"moderators"`
Domain string `json:"domain"`
OwnerHex string `json:"ownerHex"`
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
State string `json:"state"`
ImportedComments bool `json:"importedComments"`
AutoSpamFilter bool `json:"autoSpamFilter"`
RequireModeration bool `json:"requireModeration"`
RequireIdentification bool `json:"requireIdentification"`
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"`
}

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

@@ -19,17 +19,6 @@ func domainDelete(domain string) error {
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 = `
DELETE FROM views
WHERE views.domain = $1;
@@ -50,23 +39,9 @@ func domainDelete(domain string) error {
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)
// comments, votes, and pages are handled by domainClear
if err = domainClear(domain); err != nil {
logger.Errorf("cannot clear domain: %v", err)
return errorInternal
}

151
api/domain_export.go Normal file
View File

@@ -0,0 +1,151 @@
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) {
type dataExport struct {
Version int `json:"version"`
Comments []comment `json:"comments"`
Commenters []commenter `json:"commenters"`
}
e := dataExport{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,33 @@
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.gz"`, domain, creationDate.Unix()))
w.Header().Set("Content-Encoding", "gzip")
w.Write(binData)
}

View File

@@ -8,7 +8,26 @@ func domainGet(dmn string) (domain, error) {
}
statement := `
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous
SELECT
domain,
ownerHex,
name,
creationDate,
state,
importedComments,
autoSpamFilter,
requireModeration,
requireIdentification,
moderateAllAnonymous,
emailNotificationPolicy,
commentoProvider,
googleProvider,
twitterProvider,
githubProvider,
gitlabProvider,
ssoProvider,
ssoSecret,
ssoUrl
FROM domains
WHERE domain = $1;
`
@@ -16,7 +35,26 @@ func domainGet(dmn string) (domain, error) {
var err error
d := domain{}
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous); err != nil {
if err = row.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); err != nil {
return d, errorNoSuchDomain
}

View File

@@ -18,9 +18,9 @@ type disqusThread struct {
type disqusAuthor struct {
XMLName xml.Name `xml:"author"`
IsAnonymous bool `xml:"isAnonymous"`
Name string `xml:"name"`
Email string `xml:"email"`
IsAnonymous bool `xml:"isAnonymous"`
Username string `xml:"username"`
}
type disqusThreadId struct {
@@ -43,7 +43,6 @@ type disqusPost struct {
Id string `xml:"http://disqus.com/disqus-internals id,attr"`
ThreadId disqusThreadId `xml:"thread"`
ParentId disqusParentId `xml:"parent"`
PostId disqusPostId `xml:"post"`
Message string `xml:"message"`
CreationDate time.Time `xml:"createdAt"`
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
// with a random password that can be reset later).
commenterHex := make(map[string]string)
commenterHex := map[string]string{}
for _, post := range x.Posts {
if post.IsDeleted || post.IsSpam {
continue
}
if _, ok := commenterHex[post.Author.Email]; ok {
email := post.Author.Username + "@disqus.com"
if _, ok := commenterHex[email]; ok {
continue
}
c, err := commenterGetByEmail("commento", post.Author.Email)
c, err := commenterGetByEmail("commento", email)
if err != nil && err != errorNoSuchCommenter {
logger.Errorf("cannot get commenter by email: %v", err)
return 0, errorInternal
}
if err == nil {
commenterHex[post.Author.Email] = c.CommenterHex
commenterHex[email] = c.CommenterHex
continue
}
@@ -125,7 +126,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
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 {
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
// HTML to markdown.
numImported := 0
disqusIdMap := make(map[string]string)
disqusIdMap := map[string]string{}
for _, post := range x.Posts {
if post.IsDeleted || post.IsSpam {
continue
}
cHex := "anonymous"
if !post.Author.IsAnonymous {
cHex = commenterHex[post.Author.Username+"@disqus.com"]
}
parentHex := "root"
if val, ok := disqusIdMap[post.ParentId.Id]; ok {
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>
// Especially remove <img> (convert it to <a>).
commentHex, err := commentNew(
commenterHex[post.Author.Email],
cHex,
domain,
pathStrip(threads[post.ThreadId.Id].URL),
parentHex,
@@ -159,7 +165,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
return numImported, err
}
disqusIdMap[post.PostId.Id] = commentHex
disqusIdMap[post.Id] = commentHex
numImported += 1
}

View File

@@ -10,7 +10,26 @@ func domainList(ownerHex string) ([]domain, error) {
}
statement := `
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous
SELECT
domain,
ownerHex,
name,
creationDate,
state,
importedComments,
autoSpamFilter,
requireModeration,
requireIdentification,
moderateAllAnonymous,
emailNotificationPolicy,
commentoProvider,
googleProvider,
twitterProvider,
githubProvider,
gitlabProvider,
ssoProvider,
ssoSecret,
ssoUrl
FROM domains
WHERE ownerHex=$1;
`
@@ -24,7 +43,26 @@ func domainList(ownerHex string) ([]domain, error) {
domains := []domain{}
for rows.Next() {
d := domain{}
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous); err != nil {
if err = rows.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); err != nil {
logger.Errorf("cannot Scan domain: %v", err)
return nil, errorInternal
}
@@ -63,5 +101,14 @@ func domainListHandler(w http.ResponseWriter, r *http.Request) {
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
}
if err := emailNew(email); err != nil {
logger.Errorf("cannot create email when creating moderator: %v", err)
return errorInternal
}
statement := `
INSERT INTO
moderators (domain, email, addDate)

View File

@@ -2,6 +2,7 @@ package main
import (
"net/http"
"strings"
"time"
)
@@ -10,6 +11,10 @@ func domainNew(ownerHex string, name string, domain string) error {
return errorMissingField
}
if strings.Contains(domain, "/") {
return errorInvalidDomain
}
statement := `
INSERT INTO
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,46 @@ import (
)
func domainUpdate(d domain) error {
if d.SsoProvider && d.SsoUrl == "" {
return errorMissingField
}
statement := `
UPDATE domains
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6, moderateAllAnonymous=$7
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
WHERE domain=$1;
`
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification, d.ModerateAllAnonymous)
_, 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)
if err != nil {
logger.Errorf("cannot update non-moderators: %v", err)
return errorInternal

14
api/email.go Normal file
View File

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

59
api/email_get.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"net/http"
)
func emailGet(em string) (email, error) {
statement := `
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
FROM emails
WHERE email = $1;
`
row := db.QueryRow(statement, em)
e := email{}
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); err != nil {
// TODO: is this the only error?
return e, errorNoSuchEmail
}
return e, nil
}
func emailGetByUnsubscribeSecretHex(unsubscribeSecretHex string) (email, error) {
statement := `
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
FROM emails
WHERE unsubscribeSecretHex = $1;
`
row := db.QueryRow(statement, unsubscribeSecretHex)
e := email{}
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); 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})
}

66
api/email_moderate.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"fmt"
"net/http"
)
func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
unsubscribeSecretHex := r.FormValue("unsubscribeSecretHex")
e, err := emailGetByUnsubscribeSecretHex(unsubscribeSecretHex)
if err != nil {
fmt.Fprintf(w, "error: %v", err.Error())
return
}
action := r.FormValue("action")
if action != "delete" && action != "approve" {
fmt.Fprintf(w, "error: invalid action")
return
}
commentHex := r.FormValue("commentHex")
if commentHex == "" {
fmt.Fprintf(w, "error: invalid commentHex")
return
}
statement := `
SELECT domain
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, commentHex)
var domain string
if err = row.Scan(&domain); err != nil {
// TODO: is this the only error?
fmt.Fprintf(w, "error: no such comment found (perhaps it has been deleted?)")
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 checking if %s is a moderator: %v", e.Email, err)
return
}
if !isModerator {
fmt.Fprintf(w, "error: you're not a moderator for that domain")
return
}
if action == "approve" {
err = commentApprove(commentHex)
} else {
err = commentDelete(commentHex)
}
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
}

81
api/email_notification.go Normal file
View File

@@ -0,0 +1,81 @@
package main
import (
"time"
)
type emailNotification struct {
Email string
CommenterName string
Domain string
Path string
Title string
CommentHex string
Kind string
}
var emailQueue map[string](chan emailNotification) = map[string](chan emailNotification){}
func emailNotificationPendingResetAll() error {
statement := `
UPDATE emails
SET pendingEmails = 0;
`
_, err := db.Exec(statement)
if err != nil {
logger.Errorf("cannot reset pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationPendingIncrement(email string) error {
statement := `
UPDATE emails
SET pendingEmails = pendingEmails + 1
WHERE email = $1;
`
_, err := db.Exec(statement, email)
if err != nil {
logger.Errorf("cannot increment pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationPendingReset(email string) error {
statement := `
UPDATE emails
SET pendingEmails = 0, lastEmailNotificationDate = $2
WHERE email = $1;
`
_, err := db.Exec(statement, email, time.Now().UTC())
if err != nil {
logger.Errorf("cannot decrement pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationEnqueue(e emailNotification) error {
if err := emailNotificationPendingIncrement(e.Email); err != nil {
logger.Errorf("cannot increment pendingEmails when enqueueing: %v", err)
return err
}
if _, ok := emailQueue[e.Email]; !ok {
// don't enqueue more than 10 emails as we won't send more than 10 comments
// in one email anyway
emailQueue[e.Email] = make(chan emailNotification, 10)
}
select {
case emailQueue[e.Email] <- e:
default:
}
return nil
}

View File

@@ -0,0 +1,138 @@
package main
import ()
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, state string) {
if d.EmailNotificationPolicy == "none" {
return
}
// We'll need to check again when we're sending in case the comment was
// approved midway anyway.
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
}
emailNotificationPendingIncrement(m.Email)
emailNotificationEnqueue(emailNotification{
Email: m.Email,
CommenterName: commenterName,
Domain: d.Domain,
Path: path,
Title: title,
CommentHex: commentHex,
Kind: kind,
})
}
}
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex 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
}
// We'll check if they want to receive reply notifications later at the time
// of sending.
emailNotificationEnqueue(emailNotification{
Email: pc.Email,
CommenterName: commenterName,
Domain: d.Domain,
Path: path,
Title: title,
CommentHex: commentHex,
Kind: "reply",
})
}
func emailNotificationNew(d domain, path string, commenterHex string, commentHex 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 {
logger.Errorf("cannot update/get page title to send email notification: %v", err)
return
}
}
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, state)
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, parentHex, state)
}

View File

@@ -0,0 +1,63 @@
package main
import (
"html/template"
)
func emailNotificationSend(em string, kind string, notifications []emailNotification) {
if len(notifications) == 0 {
return
}
e, err := emailGet(em)
if err != nil {
logger.Errorf("cannot get email: %v", err)
return
}
messages := []emailNotificationText{}
for _, notification := range notifications {
statement := `
SELECT html
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, notification.CommentHex)
var html string
if err = row.Scan(&html); err != nil {
// the comment was deleted?
// TODO: is this the only error?
return
}
messages = append(messages, emailNotificationText{
emailNotification: notification,
Html: template.HTML(html),
})
}
statement := `
SELECT name
FROM commenters
WHERE email = $1;
`
row := db.QueryRow(statement, em)
var name string
if err := row.Scan(&name); err != nil {
// The moderator has probably not created a commenter account. Let's just
// use their email as name.
name = nameFromEmail(em)
}
if err := emailNotificationPendingReset(em); err != nil {
logger.Errorf("cannot reset after email notification: %v", err)
return
}
if err := smtpEmailNotification(em, name, e.UnsubscribeSecretHex, messages, kind); err != nil {
logger.Errorf("cannot send email notification: %v", err)
return
}
}

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

@@ -42,3 +42,7 @@ var errorInvalidConfigFile = errors.New("Invalid config file.")
var errorInvalidConfigValue = errors.New("Invalid config value.")
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 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.")

View File

@@ -2,6 +2,7 @@ package main
func main() {
exitIfError(loggerCreate())
exitIfError(versionPrint())
exitIfError(configParse())
exitIfError(dbConnect(5))
exitIfError(migrate())
@@ -9,8 +10,13 @@ func main() {
exitIfError(smtpTemplatesLoad())
exitIfError(oauthConfigure())
exitIfError(markdownRendererCreate())
exitIfError(emailNotificationPendingResetAll())
exitIfError(emailNotificationBegin())
exitIfError(sigintCleanupSetup())
exitIfError(versionCheckStart())
exitIfError(domainExportCleanupBegin())
exitIfError(viewsCleanupBegin())
exitIfError(ssoTokenCleanupBegin())
exitIfError(routesServe())
}

View File

@@ -2,14 +2,27 @@ package main
import ()
var configuredOauths []string
var googleConfigured bool
var twitterConfigured bool
var githubConfigured bool
var gitlabConfigured bool
func oauthConfigure() error {
configuredOauths = []string{}
if err := googleOauthConfigure(); err != nil {
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
}

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,129 @@
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)
}
if user["name"] == nil {
fmt.Fprintf(w, "Error: no name returned by Github")
return
}
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
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "github", "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
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)
}

42
api/oauth_gitlab.go Normal file
View File

@@ -0,0 +1,42 @@
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,
}
gitlabConfigured = true
return nil
}

View File

@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"io/ioutil"
"net/http"
)
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("https://gitlab.com/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
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "gitlab", "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
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,
}
configuredOauths = append(configuredOauths, "google")
googleConfigured = true
return nil
}

View File

@@ -39,7 +39,14 @@ func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
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 {
fmt.Fprintf(w, "Error: %s", err.Error())
return
@@ -49,14 +56,6 @@ func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
var email string
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)

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
}

116
api/oauth_sso_callback.go Normal file
View File

@@ -0,0 +1,116 @@
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
// TODO: in case of returning users, update the information we have on record?
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 {
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,108 @@
package main
import (
"encoding/json"
"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 map[string]interface{}
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
if res["email"] == nil {
fmt.Fprintf(w, "Error: no email address returned by Twitter")
return
}
email := res["email"].(string)
if res["name"] == nil {
fmt.Fprintf(w, "Error: no name returned by Twitter")
return
}
name := res["name"].(string)
link := "undefined"
photo := "undefined"
if res["handle"] != nil {
handle := res["screen_name"].(string)
link = "https://twitter.com/" + handle
photo = "https://twitter.com/" + handle + "/profile_image"
}
c, err := commenterGetByEmail("twitter", email)
if err != nil && err != errorNoSuchCommenter {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
var commenterHex string
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "twitter", "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
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,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

@@ -46,3 +46,24 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
return o, nil
}
func ownerGetByOwnerHex(ownerHex string) (owner, error) {
if ownerHex == "" {
return owner{}, errorMissingField
}
statement := `
SELECT ownerHex, email, name, confirmedEmail, joinDate
FROM owners
WHERE ownerHex = $1;
`
row := db.QueryRow(statement, ownerHex)
var o owner
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil {
logger.Errorf("cannot scan owner: %v\n", err)
return owner{}, errorInternal
}
return o, nil
}

View File

@@ -16,6 +16,14 @@ func ownerNew(email string, name string, password string) (string, error) {
return "", errorNewOwnerForbidden
}
if _, err := ownerGetByEmail(email); err == nil {
return "", errorEmailAlreadyExists
}
if err := emailNew(email); err != nil {
return "", errorInternal
}
ownerHex, err := randomHex(32)
if err != nil {
logger.Errorf("cannot generate ownerHex: %v", err)

View File

@@ -18,8 +18,9 @@ func ownerResetPassword(resetHex string, password string) error {
statement := `
UPDATE owners SET passwordHash=$1
WHERE email IN (
SELECT email FROM ownerResetHexes
WHERE ownerHex = (
SELECT ownerHex
FROM ownerResetHexes
WHERE resetHex=$2
);
`

View File

@@ -8,4 +8,5 @@ type page struct {
IsLocked bool `json:"isLocked"`
CommentCount int `json:"commentCount"`
StickyCommentHex string `json:"stickyCommentHex"`
Title string `json:"title"`
}

View File

@@ -11,14 +11,14 @@ func pageGet(domain string, path string) (page, error) {
}
statement := `
SELECT isLocked, commentCount, stickyCommentHex
SELECT isLocked, commentCount, stickyCommentHex, title
FROM pages
WHERE domain=$1 AND path=$2;
`
row := db.QueryRow(statement, domain, path)
p := page{Domain: domain, Path: path}
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex); err != nil {
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex, &p.Title); err != nil {
if err == sql.ErrNoRows {
// If there haven't been any comments, there won't be a record for this
// page. The sane thing to do is return defaults.
@@ -26,6 +26,7 @@ func pageGet(domain string, path string) (page, error) {
p.IsLocked = false
p.CommentCount = 0
p.StickyCommentHex = "none"
p.Title = ""
} else {
logger.Errorf("error scanning page: %v", err)
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

@@ -14,21 +14,42 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/domain/new", domainNewHandler).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/update", domainUpdateHandler).Methods("POST")
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST")
router.HandleFunc("/api/domain/import/disqus", domainImportDisqusHandler).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/new", commenterNewHandler).Methods("POST")
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
router.HandleFunc("/api/commenter/photo", commenterPhotoHandler).Methods("GET")
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/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/list", commentListHandler).Methods("POST")
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")

View File

@@ -36,6 +36,7 @@ func fileDetemplate(f string) ([]byte, error) {
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
}
@@ -75,7 +76,7 @@ func staticRouterInit(router *mux.Router) error {
return err
}
for _, dir := range []string{"/js", "/css", "/images"} {
for _, dir := range []string{"/js", "/css", "/images", "/fonts"} {
files, err := ioutil.ReadDir(os.Getenv("STATIC") + dir)
if err != nil {
logger.Errorf("cannot read directory %s%s: %v", os.Getenv("STATIC"), dir, err)
@@ -98,6 +99,7 @@ func staticRouterInit(router *mux.Router) error {
"/reset-password",
"/signup",
"/confirm-email",
"/unsubscribe",
"/dashboard",
"/logout",
}
@@ -115,7 +117,7 @@ func staticRouterInit(router *mux.Router) error {
if path.Ext(p) != "" {
contentType[p] = mime.TypeByExtension(path.Ext(p))
} else {
contentType[p] = mime.TypeByExtension("html")
contentType[p] = "text/html; charset=utf-8"
}
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,7 +7,14 @@ import (
)
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
}

View File

@@ -13,12 +13,16 @@ func smtpConfigure() error {
password := os.Getenv("SMTP_PASSWORD")
host := os.Getenv("SMTP_HOST")
port := os.Getenv("SMTP_PORT")
if username == "" || password == "" || host == "" || port == "" {
if host == "" || port == "" {
logger.Warningf("smtp not configured, no emails will be sent")
smtpConfigured = false
return nil
}
if username == "" || password == "" {
logger.Warningf("no SMTP username/password set, Commento will assume they aren't required")
}
if os.Getenv("SMTP_FROM_ADDRESS") == "" {
logger.Errorf("COMMENTO_SMTP_FROM_ADDRESS not set")
smtpConfigured = false

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,86 @@
package main
import (
"bytes"
"fmt"
ht "html/template"
"net/smtp"
"os"
tt "text/template"
)
type emailNotificationText struct {
emailNotification
Html ht.HTML
}
type emailNotificationPlugs struct {
Origin string
Kind string
Subject string
UnsubscribeSecretHex string
Notifications []emailNotificationText
}
func smtpEmailNotification(to string, toName string, unsubscribeSecretHex string, notifications []emailNotificationText, kind string) error {
var subject string
if kind == "reply" {
var verb string
if len(notifications) > 1 {
verb = "replies"
} else {
verb = "reply"
}
subject = fmt.Sprintf("%d new comment %s", len(notifications), verb)
} else {
var verb string
if len(notifications) > 1 {
verb = "comments"
} else {
verb = "comment"
}
if kind == "pending-moderation" {
subject = fmt.Sprintf("%d new %s pending moderation", len(notifications), verb)
} else {
subject = fmt.Sprintf("%d new %s on your website", len(notifications), verb)
}
}
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] " + subject})
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,
Subject: subject,
UnsubscribeSecretHex: unsubscribeSecretHex,
Notifications: notifications,
})
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

@@ -2,8 +2,8 @@ package main
import (
"fmt"
"html/template"
"os"
"text/template"
)
var headerTemplate *template.Template
@@ -20,9 +20,9 @@ var templates map[string]*template.Template
func smtpTemplatesLoad() error {
var err error
headerTemplate, err = template.New("header").Parse(`MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
From: {{.FromAddress}}
From: Commento <{{.FromAddress}}>
To: {{.ToName}} <{{.ToAddress}}>
Content-Type: text/plain; charset=UTF-8
Subject: {{.Subject}}
`)
@@ -31,7 +31,12 @@ Subject: {{.Subject}}
return errorMalformedTemplate
}
names := []string{"confirm-hex", "reset-hex"}
names := []string{
"confirm-hex",
"reset-hex",
"domain-export",
"domain-export-error",
}
templates = make(map[string]*template.Template)
@@ -39,9 +44,9 @@ Subject: {{.Subject}}
for _, name := range names {
var err error
templates[name] = template.New(name)
templates[name], err = template.ParseFiles(fmt.Sprintf("%s/templates/%s.html", os.Getenv("STATIC"), name))
templates[name], err = template.ParseFiles(fmt.Sprintf("%s/templates/%s.txt", os.Getenv("STATIC"), name))
if err != nil {
logger.Errorf("cannot parse %s/templates/%s.html: %v", os.Getenv("STATIC"), name, err)
logger.Errorf("cannot parse %s/templates/%s.txt: %v", os.Getenv("STATIC"), name, err)
return errorMalformedTemplate
}
}

40
api/utils_html.go Normal file
View File

@@ -0,0 +1,40 @@
package main
import (
"golang.org/x/net/html"
"net/http"
)
func htmlTitleRecurse(h *html.Node) string {
if h == nil || h.FirstChild == nil {
return ""
}
if h.Type == html.ElementNode && h.Data == "title" {
return h.FirstChild.Data
}
for c := h.FirstChild; c != nil; c = c.NextSibling {
res := htmlTitleRecurse(c)
if res != "" {
return res
}
}
return ""
}
func htmlTitleGet(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
h, err := html.Parse(resp.Body)
if err != nil {
return "", err
}
return htmlTitleRecurse(h), nil
}

View File

@@ -10,6 +10,16 @@ func concat(a bytes.Buffer, b bytes.Buffer) []byte {
return append(a.Bytes(), b.Bytes()...)
}
func nameFromEmail(email string) string {
for i, c := range email {
if c == '@' {
return email[:i]
}
}
return email
}
func exitIfError(err error) {
if err != nil {
fmt.Printf("fatal error: %v\n", err)

View File

@@ -9,6 +9,11 @@ import (
"time"
)
func versionPrint() error {
logger.Infof("starting Commento %s", version)
return nil
}
func versionCheckStart() error {
go func() {
printedError := false

View File

@@ -0,0 +1,8 @@
-- add export feature
CREATE TABLE IF NOT EXISTS exports (
exportHex TEXT NOT NULL UNIQUE PRIMARY KEY,
binData BYTEA NOT NULL,
domain TEXT NOT NULL,
creationDate TIMESTAMP NOT NULL
);

View File

@@ -0,0 +1,2 @@
UPDATE config
SET version = 'v1.5.0';

View File

@@ -0,0 +1,38 @@
-- Email notifications
-- There are two kinds of email notifications: those sent to domain moderators
-- and those sent to commenters. Domain owners can choose to subscribe their
-- moderators to all comments, those pending moderation, or no emails. Each
-- moderator can independently opt out of these emails, of course. Commenters,
-- on the other, can choose to opt into reply notifications by email.
-- TODO: daily and weekly digests instead of just batched real-time emails?
-- TODO: more granular options to unsubscribe from emails for particular
-- domains can be provided - add unsubscribedReplyDomains []TEXT and
-- unsubscribedModeratorDomains []TEXT to emails table?
-- Each address has a cooldown period so that emails aren't sent within 10
-- minutes of each other. Why is this a separate table instead of another
-- column on commenters/owners? Because there may be some mods that haven't
-- logged in to create a row in the commenter table.
CREATE TABLE IF NOT EXISTS emails (
email TEXT NOT NULL UNIQUE PRIMARY KEY,
unsubscribeSecretHex TEXT NOT NULL UNIQUE,
lastEmailNotificationDate TIMESTAMP NOT NULL,
pendingEmails INTEGER NOT NULL DEFAULT 0,
sendReplyNotifications BOOLEAN NOT NULL DEFAULT false,
sendModeratorNotifications BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX IF NOT EXISTS unsubscribeSecretHexIndex ON emails(unsubscribeSecretHex);
-- Which comments should be sent?
-- Possible values: all, pending-moderation, none
-- Default to pending-moderation because this is critical. If the user forgets
-- to moderate, some comments will never see the light of day.
ALTER TABLE domains
ADD COLUMN emailNotificationPolicy TEXT DEFAULT 'pending-moderation';
-- Each page now needs to store the title of the page.
ALTER TABLE pages
ADD COLUMN title TEXT DEFAULT '';

View File

@@ -0,0 +1,2 @@
UPDATE config
SET version = 'v1.6.0';

View File

View File

@@ -0,0 +1,2 @@
UPDATE config
SET version = 'v1.6.0';

View File

@@ -0,0 +1,16 @@
-- Make all login providers optional (but enabled by default)
ALTER TABLE domains
ADD commentoProvider BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE domains
ADD googleProvider BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE domains
ADD twitterProvider BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE domains
ADD githubProvider BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE domains
ADD gitlabProvider BOOLEAN NOT NULL DEFAULT true;

10
db/20190420181913-sso.sql Normal file
View File

@@ -0,0 +1,10 @@
-- Single Sign-On (SSO)
ALTER TABLE domains
ADD ssoProvider BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE domains
ADD ssoSecret TEXT NOT NULL DEFAULT '';
ALTER TABLE domains
ADD ssoUrl TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS ssoTokens (
token TEXT NOT NULL UNIQUE PRIMARY KEY ,
domain TEXT NOT NULL ,
commenterToken TEXT NOT NULL ,
creationDate TIMESTAMP NOT NULL
);

View File

@@ -0,0 +1,2 @@
UPDATE config
SET version = 'v1.7.0';

View File

@@ -19,12 +19,10 @@ services:
POSTGRES_DB: commento
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
networks:
- db_network
volumes:
- postgres_data_volume:/var/lib/postgres
- postgres_data_volume:/var/lib/postgresql/data
networks:
db_network:

View File

@@ -2,8 +2,8 @@
<head>
<meta name="viewport" content="user-scalable=no, initial-scale=1.0">
<script src="[[[.CdnPrefix]]]/js/jquery.js"></script>
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/auth.css">
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:400,700|Source+Sans+Pro:200,300,400,700" rel="stylesheet">
<title>Commento: Email Confirmation</title>
</head>

View File

@@ -5,9 +5,9 @@
<script src="[[[.CdnPrefix]]]/js/highlight.js"></script>
<script src="[[[.CdnPrefix]]]/js/chartist.js"></script>
<script src="[[[.CdnPrefix]]]/js/dashboard.js"></script>
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/chartist.css">
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/dashboard.css">
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:400,700|Source+Sans+Pro:200,300,400,700" rel="stylesheet">
<title>Commento: Dashboard</title>
</head>
@@ -81,23 +81,29 @@
<!-- Installation -->
<div id="installation-view" class="view hidden">
<div class="view-inside">
<div class="large-view">
<div class="mid-view">
<div class="tabs-container">
<div class="tab">
<ul class="tabs">
<li class="tab-link original current" data-tab="install-tab-1">Universal Snippet</li>
<li class="tab-link original current" data-tab="installation-tab-1">Universal Snippet</li>
</ul>
<div id="install-tab-1" class="content original current">
<div class="import-text">
<div id="installation-tab-1" class="content original current">
<div class="normal-text">
Copy the following piece of HTML code and paste it where you'd like Commento to load.
</div>
<pre><code id="code-div" class="html"></code></pre>
<div class="text">
<div class="normal-text">
And that's it. All your settings, themes, and comments would be automagically loaded. Commento is mobile-responsive too, as it simply fills the container it is put in.
</div>
<br>
<div class="normal-text">
Read the Commento documentation <a href="https://docs.commento.io/configuration/">on configuration</a>.
</div>
</div>
</div>
</div>
@@ -112,6 +118,11 @@
<div class="center center-title">
Analytics
</div>
<div class="normal-text">
Anonymous statistics such as monthly pageviews and number of comments
</div>
<div class="stat">
<div class="number">
<div class="digits gray-digits">{{domains[cd].viewsLast30Days.zeros}}</div>
@@ -142,16 +153,67 @@
<!-- moderation -->
<div id="moderation-view" class="view hidden">
<div class="view-inside">
<div class="small-view mid-view">
<div class="mid-view">
<div class="tabs-container">
<div class="tab">
<ul class="tabs">
<li class="tab-link original current" data-tab="mod-tab-1">Moderator List</li>
<li class="tab-link original current" data-tab="mod-tab-1">General</li>
<li class="tab-link" data-tab="mod-tab-2">Add/Remove Moderators</li>
</ul>
<div id="mod-tab-1" class="content original current">
<div class="pitch">
Moderators have the power to approve and delete comments. To make someone a moderator, add their email address down below. Once added, shiny new moderation buttons will appear on each comment for that person on each page on this domain.
<div class="question">
<div class="title">
Comment Filtering
</div>
<div class="answer">
<div class="row no-border commento-round-check">
<input type="checkbox" v-model="domains[cd].autoSpamFilter" id="spam-filtering">
<label for="spam-filtering">Automatic spam filtering</label>
</div>
<div class="row no-border commento-round-check">
<input type="checkbox" v-model="domains[cd].requireModeration" id="require-moderation">
<label for="require-moderation">Require all comments to be approved manually</label>
</div>
<div class="row no-border commento-round-check">
<input type="checkbox" v-model="domains[cd].moderateAllAnonymous" id="moderate-all-anonymous">
<label for="moderate-all-anonymous">Require anonymous comments to be approved manually</label>
</div>
</div>
</div>
<div class="question">
<div class="title">
Email Schedule
</div>
<div class="answer">
<div class="row no-border commento-round-check">
<input type="radio" id="email-all" value="all" v-model="domains[cd].emailNotificationPolicy">
<label for="email-all">Whenever a new comment is created</label>
</div>
<div class="row no-border commento-round-check">
<input type="radio" id="email-pending-moderation" value="pending-moderation" v-model="domains[cd].emailNotificationPolicy">
<label for="email-pending-moderation">Only for comments pending moderation</label>
</div>
<div class="row no-border commento-round-check">
<input type="radio" id="email-none" value="none" v-model="domains[cd].emailNotificationPolicy">
<label for="email-none">Do not email moderators</label>
</div>
</div>
</div>
<div class="center">
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
</div>
</div>
<div id="mod-tab-2" class="content">
<div class="normal-text">
Moderators have the power to approve/delete comments and lock threads. Once you add an user as a moderator, shiny new buttons will appear on each comment on each page when they log in.<br><br>
You're still the only administrator and the only person who can add and remove moderators. Moderators do not have access to this dashboard. Their access is restricted to pages on your website.
</div>
<div class="commento-email-container">
<div class="commento-email">
@@ -178,52 +240,103 @@
<!-- Configure Domain -->
<div id="general-view" class="view hidden">
<div class="view-inside">
<div class="small-mid-view">
<div class="center center-title">
Configure Domain
</div>
<div class="box">
<div class="row">
<div class="label">Website Name</div>
<input class="input gray-input" id="cur-domain-name" type="text" :placeholder="domains[cd].origName" v-model="domains[cd].name">
</div>
<div class="mid-view">
<div class="tabs-container">
<div class="tab">
<ul class="tabs">
<li class="tab-link original current" data-tab="configure-tab-1">General</li>
<li class="tab-link" data-tab="configure-tab-2">Export Data</li>
</ul>
<div class="row no-border round-check">
<input type="checkbox" class="switch" v-model="domains[cd].autoSpamFilter" id="spam-filtering">
<label for="spam-filtering">Automatic spam filtering</label>
<div class="pitch">
Commento uses Akismet's advanced spam detection to automatically identify and remove spam comments. We strongly recommended you have this enabled.
<div id="configure-tab-1" class="content original current">
<div class="box">
<div class="row">
<div class="label">Website Name</div>
<input class="input gray-input" id="cur-domain-name" type="text" :placeholder="domains[cd].origName" v-model="domains[cd].name">
</div>
</div>
<div class="question">
<div class="title">
Authentication Options
</div>
<div class="answer">
<div class="row no-border commento-round-check">
<input type="checkbox" v-model="domains[cd].allowAnonymous" id="allow-anonymous">
<label for="allow-anonymous">Anonymous comments</label>
</div>
<div class="row no-border commento-round-check">
<input type="checkbox" v-model="domains[cd].commentoProvider" id="commento-provider">
<label for="commento-provider">Email address login</label>
</div>
<div class="row no-border commento-round-check" v-if="configuredOauths.google">
<input type="checkbox" v-model="domains[cd].googleProvider" id="google-provider">
<label for="google-provider">Google login</label>
</div>
<div class="row no-border commento-round-check" v-if="configuredOauths.twitter">
<input type="checkbox" v-model="domains[cd].twitterProvider" id="twitter-provider">
<label for="twitter-provider">Twitter login</label>
</div>
<div class="row no-border commento-round-check" v-if="configuredOauths.github">
<input type="checkbox" v-model="domains[cd].githubProvider" id="github-provider">
<label for="github-provider">GitHub login</label>
</div>
<div class="row no-border commento-round-check" v-if="configuredOauths.gitlab">
<input type="checkbox" v-model="domains[cd].gitlabProvider" id="gitlab-provider">
<label for="gitlab-provider">GitLab login</label>
</div>
<div class="row no-border commento-round-check">
<input type="checkbox" v-model="domains[cd].ssoProvider" id="sso-provider" @change="window.commento.ssoProviderChangeHandler()">
<label for="sso-provider">Single sign-on</label>
</div>
<div class="indent" v-if="domains[cd].ssoProvider">
<div class="row">
<div class="label">HMAC shared secret key</div>
<input class="input gray-input monospace" id="sso-secret" readonly="true" type="text" placeholder="Loading..." v-model="domains[cd].ssoSecret">
</div>
<div class="row">
<div class="label">Redirect URL</div>
<input class="input gray-input" id="sso-url" type="text" :placeholder="domains[cd].ssoUrl" v-model="domains[cd].ssoUrl">
</div>
<div class="normal-text">
<div class="subtext-container">
<div class="subtext">
Read the Commento documentation <a href="https://docs.commento.io/configuration/frontend/sso.html">on single sign-on</a>.
</div>
</div>
</div>
</div>
<div class="warning" v-if="!domains[cd].allowAnonymous && !domains[cd].commentoProvider && !domains[cd].googleProvider && !domains[cd].twitterProvider && !domains[cd].githubProvider && !domains[cd].gitlabProvider">
You have disabled all authentication options. Your readers will not be able to login, create comments, or vote.
</div>
</div>
</div>
<div class="center">
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
</div>
</div>
<div id="configure-tab-2" class="content">
<div class="normal-text">
You can export an archive of this domain's data (which includes all comments and commenters) in the JSON format. To initiate and queue an archive request, click the button below. You will receive an email containing the archive once it's ready.<br><br>
Please note that this requires valid SMTP settings in order to send emails.<br><br>
<div class="center">
<button id="domain-export-button" onclick="window.commento.domainExportBegin()" class="button">Initiate Data Export</button>
</div>
</div>
</div>
</div>
<div class="row no-border round-check">
<input type="checkbox" class="switch" v-model="domains[cd].requireModeration" id="require-moderation">
<label for="require-moderation">Require all comments to be approved manually</label>
<div class="pitch">
Enabling this would require a moderator to approve every comment. Moderators can manually delete comments even if this is not enabled.
</div>
</div>
<div class="row no-border round-check">
<input type="checkbox" class="switch" v-model="domains[cd].allowAnonymous" id="allow-anonymous">
<label for="allow-anonymous">Allow anonymous comments</label>
<div class="pitch">
Enabling this would require all commenters to authenticate themselves (using their Google account, for example). Disabling would allow anonymous comments.
</div>
</div>
<div class="row no-border round-check indent" v-if="domains[cd].allowAnonymous">
<input type="checkbox" class="switch" v-model="domains[cd].moderateAllAnonymous" id="moderate-all-anonymous">
<label for="moderate-all-anonymous">Require anonymous comments to be approved manually</label>
<div class="pitch">
Enabling this would require a moderator to approve anonymous comments. This is recommended as a lot of spam is often from anonymous comments.
</div>
</div>
<div id="new-domain-error" class="modal-error-box"></div>
</div>
<div class="center">
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
</div>
</div>
</div>
@@ -232,7 +345,7 @@
<!-- Import Comments -->
<div id="import-view" class="view hidden">
<div class="view-inside">
<div class="large-view">
<div class="mid-view">
<div class="tabs-container">
<div class="tab">
<ul class="tabs">
@@ -240,15 +353,15 @@
</ul>
<div id="install-tab-1" class="content original current">
<div class="import-text">
If you're currently using Disqus and want to import all your comments into Commento, you can do so:
<div class="normal-text">
If you're currently using Disqus, you can import all comments into Commento:
<ul>
<li>
Go to <a href="http://disqus.com/admin/discussions/export/">the admin export section</a> in Disqus and click on <b>Export Comments</b>. This should start the process of exporting your comments.
Go to <a href="http://disqus.com/admin/discussions/export/">the admin export section</a> in Disqus and click on <b>Export Comments</b>. This should start the process of exporting your comments in the background.
</li>
<li>
After a while, you'll receive an email from Disqus with a link to a compressed archive of all comments and associated data. Copy and paste that link here and start the import process:
You'll receive an email from Disqus with a link to a compressed archive of all comments and associated data. Copy and paste that link here to start the import process:
<br><br>
@@ -258,14 +371,23 @@
<button id="disqus-import-button" class="commento-email-button" onclick="window.commento.importDisqus()">Import</button>
</div>
</div>
<!--
<div class="subtext-container">
<div class="subtext">
<div>Note: it is strongly recommended you do this only once. Multiple imports for the same domain may have unintended effects.</div>
<div>By using this service, you grant Commento the permission to download and process your Disqus information.</div>
</div>
</div>
-->
<br>
</li>
<li>
We'll automatically download this file, extract it, parse it and import comments into Commento. The URL information will be preserved. By using this service, you grant Commento the permission to download and process your Disqus information.
Commento will automatically download this file, extract it, parse it and import comments into Commento. URL information, comment authors, text formatting, and nested replies will be preserved.
</li>
<li>
It is strongly recommended you do this only once. Importing multiple times may have unintended effects.
</li>
</ul>
</div>
@@ -281,39 +403,67 @@
<div id="danger-view" class="view hidden">
<div class="view-inside">
<div class="mid-view">
<div class="tabs-container">
<div class="tab">
<ul class="tabs">
<li class="tab-link original current" data-tab="danger-tab-1">Freeze Comments</li>
<li class="tab-link current" data-tab="danger-tab-2">Delete Domain</li>
</ul>
<div class="center center-title">
Danger Zone
</div>
<div id="danger-tab-1" class="content original current">
<div class="box" v-if="domains[cd].state == 'frozen'">
<div class="box-subtitle">
If you desire to re-allow comments again on your website, you can do so. You can, of course, freeze the site again in the future.
<div class="action-buttons-container">
<div class="action-buttons">
<div class="action-button" v-if="domains[cd].state != 'frozen'">
<div class="left">
<div class="title">
Freeze Domain
</div>
<div class="subtitle">
Freezing your domain will disable new comments and voting temporarily. You may unfreeze the domain later.
</div>
<button onclick="document.location.hash='#unfreeze-domain-modal'" class="button green-button">Unfreeze Domain</button>
</div>
<div class="box" v-if="domains[cd].state != 'frozen'">
<div class="box-subtitle">
If you desire to temporarily freeze new comments (domain-wide), thereby making it read-only, you can do so. You can choose to unfreeze later; this is temporary.
</div>
<button id="orange-button" onclick="document.location.hash='#freeze-domain-modal'" class="button orange-button">Freeze Domain</button>
<div class="right">
<button onclick="document.location.hash='#freeze-domain-modal'"
class="button orange-button">Freeze</button>
</div>
</div>
</div>
<div id="danger-tab-2" class="content">
<div class="box">
<div class="box-subtitle">
Want to completely remove Commento from your website? This will permanently delete all comments and there is literally no way to retrieve your data once you do this.
<div class="action-button" v-if="domains[cd].state == 'frozen'">
<div class="left">
<div class="title">
Unfreeze Domain
</div>
<div class="subtitle">
Unfreezing your domain will allow readers to create new comments and vote on comments again. You may re-freeze the domain later.
</div>
</div>
<div class="right">
<button onclick="document.location.hash='#unfreeze-domain-modal'"
class="button green-button">Unfreeze</button>
</div>
</div>
<div class="action-button">
<div class="left">
<div class="title">
Clear All Comments
</div>
<div class="subtitle">
This will permanently delete all comments without affecting your settings. This may be useful if you want to clear all comments after testing Commento. Cannot be reversed.
</div>
</div>
<div class="right">
<button onclick="document.location.hash='#clear-comments-modal'"
class="button big-red-button">Clear</button>
</div>
</div>
<div class="action-button">
<div class="left">
<div class="title">
Delete Domain
</div>
<div class="subtitle">
This will permanently delete all comments and all data associated with your domain. There is literally no way to retrieve your data once you do this. Please be certain.
</div>
</div>
<div class="right">
<button onclick="document.location.hash='#delete-domain-modal'"
class="button big-red-button">Delete</button>
</div>
<button id="big-red-button" class="button big-red-button" onclick="document.location.hash='#delete-domain-modal'">Delete Domain</button>
</div>
</div>
</div>
@@ -330,8 +480,8 @@
<div class="modal-subtitle">
Are you absolutely sure you want to freeze your domain, thereby making it read-only? You can choose to unfreeze later; this is temporary.
</div>
<div class="modal-contents">
<button id="orange-button" class="button orange-button" onclick="window.commento.domainFreezeHandler()">Freeze Domain</button>
<div class="modal-contents center">
<button class="button orange-button" onclick="window.commento.domainFreezeHandler()">Freeze Domain</button>
</div>
</div>
</div>
@@ -343,8 +493,21 @@
<div class="modal-subtitle">
Are you absolutely sure you want to unfreeze your domain? This will re-allow new comments. You can choose to freeze again in the future.
</div>
<div class="modal-contents">
<button id="blue-button" class="button green-button" onclick="window.commento.domainUnfreezeHandler()">Unfreeze Domain</button>
<div class="modal-contents center">
<button class="button green-button" onclick="window.commento.domainUnfreezeHandler()">Unfreeze Domain</button>
</div>
</div>
</div>
<div id="clear-comments-modal" class="modal-window">
<div class="inside">
<a href="#modal-close" title="Close" class="modal-close"></a>
<div class="modal-title">Clear Comments</div>
<div class="modal-subtitle">
Are you absolutely sure you want to clear all comments data? This is not reversible, so please be certain.
</div>
<div class="modal-contents center">
<button class="button big-red-button" onclick="window.commento.domainClearHandler()">Clear</button>
</div>
</div>
</div>
@@ -356,8 +519,8 @@
<div class="modal-subtitle">
Are you absolutely sure? This will permanently delete all comments and there is literally no way to retrieve your data once you do this.
</div>
<div class="modal-contents">
<button id="big-red-button" class="button big-red-button" onclick="window.commento.domainDeleteHandler()">Delete Domain</button>
<div class="modal-contents center">
<button class="button big-red-button" onclick="window.commento.domainDeleteHandler()">Delete Domain</button>
</div>
</div>
</div>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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