Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504d1bf866 | ||
|
|
a4387b62ec | ||
|
|
1cce90dcf2 | ||
|
|
8820dcd59e | ||
|
|
638f7ba197 | ||
|
|
e1effd2a45 | ||
|
|
a9c48a8394 | ||
|
|
feeda79923 | ||
|
|
9d4ed4ca9f | ||
|
|
0b37b33530 | ||
|
|
b67d2ba58c | ||
|
|
5b6d31ce31 | ||
|
|
409af7f205 | ||
|
|
060520bd7f | ||
|
|
e396e043c6 | ||
|
|
4a189fc698 | ||
|
|
cac1cfa84a | ||
|
|
6317b384d9 | ||
|
|
fa2ccfe42e | ||
|
|
30772ec720 | ||
|
|
eec10491d6 | ||
|
|
e46f9cf9e7 | ||
|
|
1d1cd46c2b | ||
|
|
536ec14b93 | ||
|
|
45c6361805 | ||
|
|
a455ff54bc | ||
|
|
0e54739980 | ||
|
|
7e9b3e5b26 | ||
|
|
86393ad9ab | ||
|
|
672863b48f | ||
|
|
97f17d32ee | ||
|
|
be10baf971 | ||
|
|
9607c15c2b | ||
|
|
65ea597c08 | ||
|
|
850dfc9712 | ||
|
|
3c9ba43ad1 | ||
|
|
b4790397c9 | ||
|
|
9d6955b81e | ||
|
|
5ffdf9988a | ||
|
|
5f1d46c7b2 | ||
|
|
a2c8a73d3e | ||
|
|
4945e53553 | ||
|
|
88d4f8afcf | ||
|
|
15b1640f89 | ||
|
|
216016a4be | ||
|
|
a7cd8066f8 | ||
|
|
295318e6a6 | ||
|
|
d26b6f6e9f | ||
|
|
c8a2ece0d6 | ||
|
|
e9ba79974b | ||
|
|
beb54035cf | ||
|
|
1ccc95fae4 | ||
|
|
fa3fa39696 | ||
|
|
b9bf9e360a | ||
|
|
ecbb505c97 | ||
|
|
789a58bd7a | ||
|
|
c30da607cb | ||
|
|
be197f2b69 | ||
|
|
d4b466b04f | ||
|
|
95093326e0 | ||
|
|
3e5c1c2656 | ||
|
|
c07f3e8b9f | ||
|
|
d367ac8391 | ||
|
|
0609ef0e27 | ||
|
|
adb87d7029 | ||
|
|
23bec48ebb | ||
|
|
685f3a3a58 | ||
|
|
f4489c9921 | ||
|
|
352c93bf88 | ||
|
|
27caa60e0c | ||
|
|
e0f188909f | ||
|
|
0b78e9e70c | ||
|
|
ca797cd165 | ||
|
|
15d729c6ac | ||
|
|
af1d1dcd0c | ||
|
|
b21c630208 | ||
|
|
ef68dadcd7 | ||
|
|
8a7348ed6a | ||
|
|
5df5b5f112 | ||
|
|
c9677385f8 | ||
|
|
220109a157 | ||
|
|
2e2d022c9b | ||
|
|
63c4da0b8d | ||
|
|
e1c94ecf15 | ||
|
|
60a9f2cc15 | ||
|
|
06f0f6f014 | ||
|
|
69aba94590 | ||
|
|
52ce1e2660 | ||
|
|
7fc3910009 | ||
|
|
619231e32f | ||
|
|
a22b49a112 | ||
|
|
b77089388f | ||
|
|
b35155b9e5 | ||
|
|
5bb51bb131 | ||
|
|
8a8e0b53fc | ||
|
|
2b00384219 | ||
|
|
bd695c53fd | ||
|
|
24ddf0657b | ||
|
|
e70546fb56 | ||
|
|
4ceb85ae51 | ||
|
|
41b0c8e5ca | ||
|
|
0acdd67e39 | ||
|
|
caca7b8c41 | ||
|
|
24de2dbcb3 | ||
|
|
9a14801990 | ||
|
|
de98ed81cd | ||
|
|
3f7b65dee9 | ||
|
|
8c09aa0ff6 | ||
|
|
ce47f80e8e | ||
|
|
e434f59f9a | ||
|
|
a4fbf67d73 | ||
|
|
1aea90cb07 | ||
|
|
20b6660fa9 | ||
|
|
815628c5ee | ||
|
|
6caa3e312c | ||
|
|
94829d9b83 | ||
|
|
7be22b091f | ||
|
|
fff5e5c0e1 | ||
|
|
f1ece27c99 | ||
|
|
5e48da6940 | ||
|
|
28fe1aaa89 | ||
|
|
f846935a2a | ||
|
|
42b452b9f8 | ||
|
|
514535a607 | ||
|
|
55f24b2de2 | ||
|
|
24d76c2fb6 | ||
|
|
f2ff2b4940 | ||
|
|
6d1563e22a | ||
|
|
9a3c181442 | ||
|
|
010b7336cd | ||
|
|
00c197e2ee | ||
|
|
c6a98d93e4 | ||
|
|
edd8aae7a7 | ||
|
|
3677d43aab |
@@ -50,8 +50,8 @@ aws-upload-tags:
|
|||||||
- export PATH=$PATH:/go/bin
|
- export PATH=$PATH:/go/bin
|
||||||
- cd /go/src/$CI_PROJECT_NAME
|
- cd /go/src/$CI_PROJECT_NAME
|
||||||
- make prod
|
- make prod
|
||||||
- cd build/prod && tar -zcvf /commento-linux-amd64-$(git describe --tags).tgz .
|
- cd build/prod && tar -zcvf /commento-linux-amd64-$(git describe --tags).tar.gz .
|
||||||
- aws s3 cp /commento-linux-amd64-$(git describe --tags).tgz s3://commento-release/
|
- aws s3 cp /commento-linux-amd64-$(git describe --tags).tar.gz s3://commento-release/
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
stage: build-docker
|
stage: build-docker
|
||||||
|
|||||||
@@ -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/css/*.css /commento/css/
|
||||||
COPY --from=frontend-build /commento/frontend/build/prod/js/*.js /commento/js/
|
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/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=templates-build /commento/templates/build/prod/templates/ /commento/templates/
|
||||||
COPY --from=db-build /commento/db/build/prod/db/ /commento/db/
|
COPY --from=db-build /commento/db/build/prod/db/ /commento/db/
|
||||||
|
|
||||||
|
|||||||
16
api/Gopkg.lock
generated
16
api/Gopkg.lock
generated
@@ -25,6 +25,14 @@
|
|||||||
revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
|
revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
|
||||||
version = "v1.1.0"
|
version = "v1.1.0"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
branch = "master"
|
||||||
|
digest = "1:d03d0fae6a7a80e89c540787a69ab6e0d3b773fdb3303c0b3d96a15490c6ef32"
|
||||||
|
name = "github.com/gomodule/oauth1"
|
||||||
|
packages = ["oauth"]
|
||||||
|
pruneopts = "UT"
|
||||||
|
revision = "9a59ed3b0a84f454c260f2f8f82918223fc5630f"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1"
|
digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1"
|
||||||
name = "github.com/gorilla/context"
|
name = "github.com/gorilla/context"
|
||||||
@@ -116,10 +124,12 @@
|
|||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
branch = "master"
|
branch = "master"
|
||||||
digest = "1:bea0314c10bd362ab623af4880d853b5bad3b63d0ab9945c47e461b8d04203ed"
|
digest = "1:341ceeee37101c62dae441691406bf4ecc71bbeb7b424417879fe88d9f88f487"
|
||||||
name = "golang.org/x/oauth2"
|
name = "golang.org/x/oauth2"
|
||||||
packages = [
|
packages = [
|
||||||
".",
|
".",
|
||||||
|
"github",
|
||||||
|
"gitlab",
|
||||||
"google",
|
"google",
|
||||||
"internal",
|
"internal",
|
||||||
"jws",
|
"jws",
|
||||||
@@ -152,6 +162,7 @@
|
|||||||
analyzer-version = 1
|
analyzer-version = 1
|
||||||
input-imports = [
|
input-imports = [
|
||||||
"github.com/adtac/go-akismet/akismet",
|
"github.com/adtac/go-akismet/akismet",
|
||||||
|
"github.com/gomodule/oauth1/oauth",
|
||||||
"github.com/gorilla/handlers",
|
"github.com/gorilla/handlers",
|
||||||
"github.com/gorilla/mux",
|
"github.com/gorilla/mux",
|
||||||
"github.com/lib/pq",
|
"github.com/lib/pq",
|
||||||
@@ -160,7 +171,10 @@
|
|||||||
"github.com/op/go-logging",
|
"github.com/op/go-logging",
|
||||||
"github.com/russross/blackfriday",
|
"github.com/russross/blackfriday",
|
||||||
"golang.org/x/crypto/bcrypt",
|
"golang.org/x/crypto/bcrypt",
|
||||||
|
"golang.org/x/net/html",
|
||||||
"golang.org/x/oauth2",
|
"golang.org/x/oauth2",
|
||||||
|
"golang.org/x/oauth2/github",
|
||||||
|
"golang.org/x/oauth2/gitlab",
|
||||||
"golang.org/x/oauth2/google",
|
"golang.org/x/oauth2/google",
|
||||||
]
|
]
|
||||||
solver-name = "gps-cdcl"
|
solver-name = "gps-cdcl"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type comment struct {
|
|||||||
Html string `json:"html"`
|
Html string `json:"html"`
|
||||||
ParentHex string `json:"parentHex"`
|
ParentHex string `json:"parentHex"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
State string `json:"state"`
|
State string `json:"state,omitempty"`
|
||||||
CreationDate time.Time `json:"creationDate"`
|
CreationDate time.Time `json:"creationDate"`
|
||||||
Direction int `json:"direction"`
|
Direction int `json:"direction"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/lib/pq"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func commentCount(domain string, path string) (int, error) {
|
func commentCount(domain string, paths []string) (map[string]int, error) {
|
||||||
// path can be empty
|
commentCounts := map[string]int{}
|
||||||
|
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
return 0, errorMissingField
|
return nil, errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := pageGet(domain, path)
|
if len(paths) == 0 {
|
||||||
|
return nil, errorEmptyPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT path, commentCount
|
||||||
|
FROM pages
|
||||||
|
WHERE domain = $1 AND path = ANY($2);
|
||||||
|
`
|
||||||
|
rows, err := db.Query(statement, domain, pq.Array(paths))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, errorInternal
|
logger.Errorf("cannot get comments: %v", err)
|
||||||
|
return nil, errorInternal
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var path string
|
||||||
|
var commentCount int
|
||||||
|
if err = rows.Scan(&path, &commentCount); err != nil {
|
||||||
|
logger.Errorf("cannot scan path and commentCount: %v", err)
|
||||||
|
return nil, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
commentCounts[path] = commentCount
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.CommentCount, nil
|
return commentCounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func commentCountHandler(w http.ResponseWriter, r *http.Request) {
|
func commentCountHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
type request struct {
|
type request struct {
|
||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
Path *string `json:"path"`
|
Paths *[]string `json:"paths"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var x request
|
var x request
|
||||||
@@ -31,13 +55,12 @@ func commentCountHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
domain := domainStrip(*x.Domain)
|
domain := domainStrip(*x.Domain)
|
||||||
path := *x.Path
|
|
||||||
|
|
||||||
count, err := commentCount(domain, path)
|
commentCounts, err := commentCount(domain, *x.Paths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "count": count})
|
bodyMarshal(w, response{"success": true, "commentCounts": commentCounts})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ func TestCommentCountBasics(t *testing.T) {
|
|||||||
commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC())
|
commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC())
|
||||||
commentNew(commenterHex, "example.com", "/path.html", "root", "**baz**", "unapproved", time.Now().UTC())
|
commentNew(commenterHex, "example.com", "/path.html", "root", "**baz**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
count, err := commentCount("example.com", "/path.html")
|
counts, err := commentCount("example.com", []string{"/path.html"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("unexpected error counting comments: %v", err)
|
t.Errorf("unexpected error counting comments: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count != 2 {
|
if counts["/path.html"] != 2 {
|
||||||
t.Errorf("expected count=2 got count=%d", count)
|
t.Errorf("expected count=2 got count=%d", counts["/path.html"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,25 +29,25 @@ func TestCommentCountBasics(t *testing.T) {
|
|||||||
func TestCommentCountNewPage(t *testing.T) {
|
func TestCommentCountNewPage(t *testing.T) {
|
||||||
failTestOnError(t, setupTestEnv())
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
count, err := commentCount("example.com", "/path.html")
|
counts, err := commentCount("example.com", []string{"/path.html"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("unexpected error counting comments: %v", err)
|
t.Errorf("unexpected error counting comments: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count != 0 {
|
if counts["/path.html"] != 0 {
|
||||||
t.Errorf("expected count=0 got count=%d", count)
|
t.Errorf("expected count=0 got count=%d", counts["/path.html"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommentCountEmpty(t *testing.T) {
|
func TestCommentCountEmpty(t *testing.T) {
|
||||||
if _, err := commentCount("example.com", ""); err != nil {
|
if _, err := commentCount("example.com", []string{""}); err != nil {
|
||||||
t.Errorf("unexpected error counting comments on empty path: %v", err)
|
t.Errorf("unexpected error counting comments on empty path: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := commentCount("", ""); err == nil {
|
if _, err := commentCount("", []string{""}); err == nil {
|
||||||
t.Errorf("expected error not found counting comments with empty everything")
|
t.Errorf("expected error not found counting comments with empty everything")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,26 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT commentHex, commenterHex, markdown, html, parentHex, score, state, creationDate
|
SELECT
|
||||||
|
commentHex,
|
||||||
|
commenterHex,
|
||||||
|
markdown,
|
||||||
|
html,
|
||||||
|
parentHex,
|
||||||
|
score,
|
||||||
|
state,
|
||||||
|
creationDate
|
||||||
FROM comments
|
FROM comments
|
||||||
WHERE
|
WHERE
|
||||||
comments.domain = $1 AND
|
comments.domain = $1 AND
|
||||||
comments.path = $2
|
comments.path = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
if !includeUnapproved {
|
if !includeUnapproved {
|
||||||
if commenterHex == "anonymous" {
|
if commenterHex == "anonymous" {
|
||||||
statement += `
|
statement += `AND state = 'approved'`
|
||||||
AND state = 'approved'
|
|
||||||
`
|
|
||||||
} else {
|
} else {
|
||||||
statement += `
|
statement += `AND (state = 'approved' OR commenterHex = $3)`
|
||||||
AND (state = 'approved' OR commenterHex = $3)
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,16 +58,24 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
|||||||
comments := []comment{}
|
comments := []comment{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
c := comment{}
|
c := comment{}
|
||||||
if err = rows.Scan(&c.CommentHex, &c.CommenterHex, &c.Markdown, &c.Html, &c.ParentHex, &c.Score, &c.State, &c.CreationDate); err != nil {
|
if err = rows.Scan(
|
||||||
|
&c.CommentHex,
|
||||||
|
&c.CommenterHex,
|
||||||
|
&c.Markdown,
|
||||||
|
&c.Html,
|
||||||
|
&c.ParentHex,
|
||||||
|
&c.Score,
|
||||||
|
&c.State,
|
||||||
|
&c.CreationDate); err != nil {
|
||||||
return nil, nil, errorInternal
|
return nil, nil, errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
if commenterHex != "anonymous" {
|
if commenterHex != "anonymous" {
|
||||||
statement = `
|
statement = `
|
||||||
SELECT direction
|
SELECT direction
|
||||||
FROM votes
|
FROM votes
|
||||||
WHERE commentHex=$1 AND commenterHex=$2;
|
WHERE commentHex=$1 AND commenterHex=$2;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, c.CommentHex, commenterHex)
|
row := db.QueryRow(statement, c.CommentHex, commenterHex)
|
||||||
|
|
||||||
if err = row.Scan(&c.Direction); err != nil {
|
if err = row.Scan(&c.Direction); err != nil {
|
||||||
@@ -120,6 +132,8 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
commenterHex := "anonymous"
|
commenterHex := "anonymous"
|
||||||
isModerator := false
|
isModerator := false
|
||||||
|
modList := map[string]bool{}
|
||||||
|
|
||||||
if *x.CommenterToken != "anonymous" {
|
if *x.CommenterToken != "anonymous" {
|
||||||
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -134,11 +148,15 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, mod := range d.Moderators {
|
for _, mod := range d.Moderators {
|
||||||
|
modList[mod.Email] = true
|
||||||
if mod.Email == c.Email {
|
if mod.Email == c.Email {
|
||||||
isModerator = true
|
isModerator = true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
for _, mod := range d.Moderators {
|
||||||
|
modList[mod.Email] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
domainViewRecord(domain, commenterHex)
|
domainViewRecord(domain, commenterHex)
|
||||||
@@ -149,16 +167,32 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_commenters := map[string]commenter{}
|
||||||
|
for commenterHex, cr := range commenters {
|
||||||
|
if _, ok := modList[cr.Email]; ok {
|
||||||
|
cr.IsModerator = true
|
||||||
|
}
|
||||||
|
cr.Email = ""
|
||||||
|
_commenters[commenterHex] = cr
|
||||||
|
}
|
||||||
|
|
||||||
bodyMarshal(w, response{
|
bodyMarshal(w, response{
|
||||||
"success": true,
|
"success": true,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
"commenters": commenters,
|
"commenters": _commenters,
|
||||||
"requireModeration": d.RequireModeration,
|
"requireModeration": d.RequireModeration,
|
||||||
"requireIdentification": d.RequireIdentification,
|
"requireIdentification": d.RequireIdentification,
|
||||||
"isFrozen": d.State == "frozen",
|
"isFrozen": d.State == "frozen",
|
||||||
"isModerator": isModerator,
|
"isModerator": isModerator,
|
||||||
"attributes": p,
|
"attributes": p,
|
||||||
"configuredOauths": configuredOauths,
|
"configuredOauths": map[string]bool{
|
||||||
|
"commento": d.CommentoProvider,
|
||||||
|
"google": googleConfigured && d.GoogleProvider,
|
||||||
|
"twitter": twitterConfigured && d.TwitterProvider,
|
||||||
|
"github": githubConfigured && d.GithubProvider,
|
||||||
|
"gitlab": gitlabConfigured && d.GitlabProvider,
|
||||||
|
"sso": d.SsoProvider,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
// logic: (empty column indicates the value doesn't matter)
|
||||||
// | anonymous | moderator | requireIdentification | requireModeration | moderateAllAnonymous | approved? |
|
// | 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) {
|
if isSpam(*x.Domain, getIp(r), getUserAgent(r), "Anonymous", "", "", *x.Markdown) {
|
||||||
state = "flagged"
|
state = "flagged"
|
||||||
} else {
|
} else {
|
||||||
if d.ModerateAllAnonymous {
|
if d.ModerateAllAnonymous || d.RequireModeration {
|
||||||
state = "unapproved"
|
state = "unapproved"
|
||||||
} else {
|
} else {
|
||||||
state = "approved"
|
state = "approved"
|
||||||
@@ -139,5 +144,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ type commenter struct {
|
|||||||
Photo string `json:"photo"`
|
Photo string `json:"photo"`
|
||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
JoinDate time.Time `json:"joinDate,omitempty"`
|
JoinDate time.Time `json:"joinDate,omitempty"`
|
||||||
|
IsModerator bool `json:"isModerator"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,5 +66,6 @@ func commenterGetByCommenterToken(commenterToken string) (commenter, error) {
|
|||||||
return commenter{}, errorNoSuchToken
|
return commenter{}, errorNoSuchToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: use a join instead of two queries?
|
||||||
return commenterGetByHex(commenterHex)
|
return commenterGetByHex(commenterHex)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,5 +67,12 @@ func commenterLoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken})
|
// TODO: modify commenterLogin to directly return c?
|
||||||
|
c, err := commenterGetByCommenterToken(commenterToken)
|
||||||
|
if err != nil {
|
||||||
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken, "commenter": c})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ func commenterNew(email string, name string, link string, photo string, provider
|
|||||||
return "", errorEmailAlreadyExists
|
return "", errorEmailAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := emailNew(email); err != nil {
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
commenterHex, err := randomHex(32)
|
commenterHex, err := randomHex(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errorInternal
|
return "", errorInternal
|
||||||
|
|||||||
34
api/commenter_photo.go
Normal file
34
api/commenter_photo.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -21,5 +21,11 @@ func commenterSelfHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "commenter": c})
|
e, err := emailGet(c.Email)
|
||||||
|
if err != nil {
|
||||||
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyMarshal(w, response{"success": true, "commenter": c, "email": e})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ func configParse() error {
|
|||||||
|
|
||||||
"GOOGLE_KEY": "",
|
"GOOGLE_KEY": "",
|
||||||
"GOOGLE_SECRET": "",
|
"GOOGLE_SECRET": "",
|
||||||
|
|
||||||
|
"GITHUB_KEY": "",
|
||||||
|
"GITHUB_SECRET": "",
|
||||||
|
|
||||||
|
"TWITTER_KEY": "",
|
||||||
|
"TWITTER_SECRET": "",
|
||||||
|
|
||||||
|
"GITLAB_KEY": "",
|
||||||
|
"GITLAB_SECRET": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range defaults {
|
for key, value := range defaults {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
var version = "v1.4.2"
|
var version = "v1.7.0"
|
||||||
|
|||||||
25
api/cron_domain_export_cleanup.go
Normal file
25
api/cron_domain_export_cleanup.go
Normal 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
|
||||||
|
}
|
||||||
66
api/cron_email_notification.go
Normal file
66
api/cron_email_notification.go
Normal 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
25
api/cron_sso_token.go
Normal 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
25
api/cron_views_cleanup.go
Normal 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
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var goMigrations = map[string](func() error){
|
||||||
|
"20190213033530-email-notifications.sql": migrateEmails,
|
||||||
|
}
|
||||||
|
|
||||||
func migrate() error {
|
func migrate() error {
|
||||||
return migrateFromDir(os.Getenv("STATIC") + "/db")
|
return migrateFromDir(os.Getenv("STATIC") + "/db")
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,13 @@ func migrateFromDir(dir string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fn, ok := goMigrations[file.Name()]; ok {
|
||||||
|
if err = fn(); err != nil {
|
||||||
|
logger.Errorf("cannot execute Go migration associated with SQL %s: %v", f, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
completed++
|
completed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
api/database_migrate_email_notifications.go
Normal file
37
api/database_migrate_email_notifications.go
Normal 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
|
||||||
|
}
|
||||||
@@ -5,15 +5,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type domain struct {
|
type domain struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
OwnerHex string `json:"ownerHex"`
|
OwnerHex string `json:"ownerHex"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreationDate time.Time `json:"creationDate"`
|
CreationDate time.Time `json:"creationDate"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
ImportedComments bool `json:"importedComments"`
|
ImportedComments bool `json:"importedComments"`
|
||||||
AutoSpamFilter bool `json:"autoSpamFilter"`
|
AutoSpamFilter bool `json:"autoSpamFilter"`
|
||||||
RequireModeration bool `json:"requireModeration"`
|
RequireModeration bool `json:"requireModeration"`
|
||||||
RequireIdentification bool `json:"requireIdentification"`
|
RequireIdentification bool `json:"requireIdentification"`
|
||||||
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
|
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
|
||||||
Moderators []moderator `json:"moderators"`
|
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
82
api/domain_clear.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -19,17 +19,6 @@ func domainDelete(domain string) error {
|
|||||||
return errorNoSuchDomain
|
return errorNoSuchDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
statement = `
|
|
||||||
DELETE FROM votes
|
|
||||||
USING comments
|
|
||||||
WHERE comments.commentHex = votes.commentHex AND comments.domain = $1;
|
|
||||||
`
|
|
||||||
_, err = db.Exec(statement, domain)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot delete votes: %v", err)
|
|
||||||
return errorInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
statement = `
|
statement = `
|
||||||
DELETE FROM views
|
DELETE FROM views
|
||||||
WHERE views.domain = $1;
|
WHERE views.domain = $1;
|
||||||
@@ -50,23 +39,9 @@ func domainDelete(domain string) error {
|
|||||||
return errorInternal
|
return errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
statement = `
|
// comments, votes, and pages are handled by domainClear
|
||||||
DELETE FROM comments
|
if err = domainClear(domain); err != nil {
|
||||||
WHERE comments.domain = $1;
|
logger.Errorf("cannot clear domain: %v", err)
|
||||||
`
|
|
||||||
_, 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 errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
151
api/domain_export.go
Normal file
151
api/domain_export.go
Normal 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})
|
||||||
|
}
|
||||||
33
api/domain_export_download.go
Normal file
33
api/domain_export_download.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -8,7 +8,26 @@ func domainGet(dmn string) (domain, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
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
|
FROM domains
|
||||||
WHERE domain = $1;
|
WHERE domain = $1;
|
||||||
`
|
`
|
||||||
@@ -16,7 +35,26 @@ func domainGet(dmn string) (domain, error) {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
d := domain{}
|
d := domain{}
|
||||||
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &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
|
return d, errorNoSuchDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ type disqusThread struct {
|
|||||||
|
|
||||||
type disqusAuthor struct {
|
type disqusAuthor struct {
|
||||||
XMLName xml.Name `xml:"author"`
|
XMLName xml.Name `xml:"author"`
|
||||||
IsAnonymous bool `xml:"isAnonymous"`
|
|
||||||
Name string `xml:"name"`
|
Name string `xml:"name"`
|
||||||
Email string `xml:"email"`
|
IsAnonymous bool `xml:"isAnonymous"`
|
||||||
|
Username string `xml:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type disqusThreadId struct {
|
type disqusThreadId struct {
|
||||||
@@ -43,7 +43,6 @@ type disqusPost struct {
|
|||||||
Id string `xml:"http://disqus.com/disqus-internals id,attr"`
|
Id string `xml:"http://disqus.com/disqus-internals id,attr"`
|
||||||
ThreadId disqusThreadId `xml:"thread"`
|
ThreadId disqusThreadId `xml:"thread"`
|
||||||
ParentId disqusParentId `xml:"parent"`
|
ParentId disqusParentId `xml:"parent"`
|
||||||
PostId disqusPostId `xml:"post"`
|
|
||||||
Message string `xml:"message"`
|
Message string `xml:"message"`
|
||||||
CreationDate time.Time `xml:"createdAt"`
|
CreationDate time.Time `xml:"createdAt"`
|
||||||
IsDeleted bool `xml:"isDeleted"`
|
IsDeleted bool `xml:"isDeleted"`
|
||||||
@@ -98,24 +97,26 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
|||||||
|
|
||||||
// Map Disqus emails to commenterHex (if not available, create a new one
|
// Map Disqus emails to commenterHex (if not available, create a new one
|
||||||
// with a random password that can be reset later).
|
// with a random password that can be reset later).
|
||||||
commenterHex := make(map[string]string)
|
commenterHex := map[string]string{}
|
||||||
for _, post := range x.Posts {
|
for _, post := range x.Posts {
|
||||||
if post.IsDeleted || post.IsSpam {
|
if post.IsDeleted || post.IsSpam {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := commenterHex[post.Author.Email]; ok {
|
email := post.Author.Username + "@disqus.com"
|
||||||
|
|
||||||
|
if _, ok := commenterHex[email]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
c, err := commenterGetByEmail("commento", post.Author.Email)
|
c, err := commenterGetByEmail("commento", email)
|
||||||
if err != nil && err != errorNoSuchCommenter {
|
if err != nil && err != errorNoSuchCommenter {
|
||||||
logger.Errorf("cannot get commenter by email: %v", err)
|
logger.Errorf("cannot get commenter by email: %v", err)
|
||||||
return 0, errorInternal
|
return 0, errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
commenterHex[post.Author.Email] = c.CommenterHex
|
commenterHex[email] = c.CommenterHex
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
|||||||
return 0, errorInternal
|
return 0, errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
commenterHex[post.Author.Email], err = commenterNew(post.Author.Email, post.Author.Name, "undefined", "undefined", "commento", randomPassword)
|
commenterHex[email], err = commenterNew(email, post.Author.Name, "undefined", "undefined", "commento", randomPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -134,12 +135,17 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
|||||||
// For each Disqus post, create a Commento comment. Attempt to convert the
|
// For each Disqus post, create a Commento comment. Attempt to convert the
|
||||||
// HTML to markdown.
|
// HTML to markdown.
|
||||||
numImported := 0
|
numImported := 0
|
||||||
disqusIdMap := make(map[string]string)
|
disqusIdMap := map[string]string{}
|
||||||
for _, post := range x.Posts {
|
for _, post := range x.Posts {
|
||||||
if post.IsDeleted || post.IsSpam {
|
if post.IsDeleted || post.IsSpam {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cHex := "anonymous"
|
||||||
|
if !post.Author.IsAnonymous {
|
||||||
|
cHex = commenterHex[post.Author.Username+"@disqus.com"]
|
||||||
|
}
|
||||||
|
|
||||||
parentHex := "root"
|
parentHex := "root"
|
||||||
if val, ok := disqusIdMap[post.ParentId.Id]; ok {
|
if val, ok := disqusIdMap[post.ParentId.Id]; ok {
|
||||||
parentHex = val
|
parentHex = val
|
||||||
@@ -148,7 +154,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
|||||||
// TODO: restrict the list of tags to just the basics: <a>, <b>, <i>, <code>
|
// TODO: restrict the list of tags to just the basics: <a>, <b>, <i>, <code>
|
||||||
// Especially remove <img> (convert it to <a>).
|
// Especially remove <img> (convert it to <a>).
|
||||||
commentHex, err := commentNew(
|
commentHex, err := commentNew(
|
||||||
commenterHex[post.Author.Email],
|
cHex,
|
||||||
domain,
|
domain,
|
||||||
pathStrip(threads[post.ThreadId.Id].URL),
|
pathStrip(threads[post.ThreadId.Id].URL),
|
||||||
parentHex,
|
parentHex,
|
||||||
@@ -159,7 +165,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
|||||||
return numImported, err
|
return numImported, err
|
||||||
}
|
}
|
||||||
|
|
||||||
disqusIdMap[post.PostId.Id] = commentHex
|
disqusIdMap[post.Id] = commentHex
|
||||||
numImported += 1
|
numImported += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,26 @@ func domainList(ownerHex string) ([]domain, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
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
|
FROM domains
|
||||||
WHERE ownerHex=$1;
|
WHERE ownerHex=$1;
|
||||||
`
|
`
|
||||||
@@ -24,7 +43,26 @@ func domainList(ownerHex string) ([]domain, error) {
|
|||||||
domains := []domain{}
|
domains := []domain{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
d := domain{}
|
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)
|
logger.Errorf("cannot Scan domain: %v", err)
|
||||||
return nil, errorInternal
|
return nil, errorInternal
|
||||||
}
|
}
|
||||||
@@ -63,5 +101,14 @@ func domainListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "domains": domains})
|
bodyMarshal(w, response{
|
||||||
|
"success": true,
|
||||||
|
"domains": domains,
|
||||||
|
"configuredOauths": map[string]bool{
|
||||||
|
"google": googleConfigured,
|
||||||
|
"twitter": twitterConfigured,
|
||||||
|
"github": githubConfigured,
|
||||||
|
"gitlab": gitlabConfigured,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ func domainModeratorNew(domain string, email string) error {
|
|||||||
return errorMissingField
|
return errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := emailNew(email); err != nil {
|
||||||
|
logger.Errorf("cannot create email when creating moderator: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
moderators (domain, email, addDate)
|
moderators (domain, email, addDate)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@ func domainNew(ownerHex string, name string, domain string) error {
|
|||||||
return errorMissingField
|
return errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(domain, "/") {
|
||||||
|
return errorInvalidDomain
|
||||||
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
domains (ownerHex, name, domain, creationDate)
|
domains (ownerHex, name, domain, creationDate)
|
||||||
|
|||||||
69
api/domain_sso.go
Normal file
69
api/domain_sso.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -5,13 +5,46 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func domainUpdate(d domain) error {
|
func domainUpdate(d domain) error {
|
||||||
|
if d.SsoProvider && d.SsoUrl == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
UPDATE domains
|
UPDATE domains
|
||||||
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6, 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;
|
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 {
|
if err != nil {
|
||||||
logger.Errorf("cannot update non-moderators: %v", err)
|
logger.Errorf("cannot update non-moderators: %v", err)
|
||||||
return errorInternal
|
return errorInternal
|
||||||
|
|||||||
14
api/email.go
Normal file
14
api/email.go
Normal 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
59
api/email_get.go
Normal 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
66
api/email_moderate.go
Normal 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
26
api/email_new.go
Normal 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
81
api/email_notification.go
Normal 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
|
||||||
|
}
|
||||||
138
api/email_notification_new.go
Normal file
138
api/email_notification_new.go
Normal 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)
|
||||||
|
}
|
||||||
63
api/email_notification_send.go
Normal file
63
api/email_notification_send.go
Normal 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
39
api/email_update.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -42,3 +42,7 @@ var errorInvalidConfigFile = errors.New("Invalid config file.")
|
|||||||
var errorInvalidConfigValue = errors.New("Invalid config value.")
|
var errorInvalidConfigValue = errors.New("Invalid config value.")
|
||||||
var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.")
|
var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.")
|
||||||
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
|
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
|
||||||
|
var errorDatabaseMigration = errors.New("Encountered error applying database migration.")
|
||||||
|
var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.")
|
||||||
|
var errorEmptyPaths = errors.New("Empty paths field.")
|
||||||
|
var errorInvalidDomain = errors.New("Invalid domain name. Do not include the URL path after the forward slash.")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
exitIfError(loggerCreate())
|
exitIfError(loggerCreate())
|
||||||
|
exitIfError(versionPrint())
|
||||||
exitIfError(configParse())
|
exitIfError(configParse())
|
||||||
exitIfError(dbConnect(5))
|
exitIfError(dbConnect(5))
|
||||||
exitIfError(migrate())
|
exitIfError(migrate())
|
||||||
@@ -9,8 +10,13 @@ func main() {
|
|||||||
exitIfError(smtpTemplatesLoad())
|
exitIfError(smtpTemplatesLoad())
|
||||||
exitIfError(oauthConfigure())
|
exitIfError(oauthConfigure())
|
||||||
exitIfError(markdownRendererCreate())
|
exitIfError(markdownRendererCreate())
|
||||||
|
exitIfError(emailNotificationPendingResetAll())
|
||||||
|
exitIfError(emailNotificationBegin())
|
||||||
exitIfError(sigintCleanupSetup())
|
exitIfError(sigintCleanupSetup())
|
||||||
exitIfError(versionCheckStart())
|
exitIfError(versionCheckStart())
|
||||||
|
exitIfError(domainExportCleanupBegin())
|
||||||
|
exitIfError(viewsCleanupBegin())
|
||||||
|
exitIfError(ssoTokenCleanupBegin())
|
||||||
|
|
||||||
exitIfError(routesServe())
|
exitIfError(routesServe())
|
||||||
}
|
}
|
||||||
|
|||||||
19
api/oauth.go
19
api/oauth.go
@@ -2,14 +2,27 @@ package main
|
|||||||
|
|
||||||
import ()
|
import ()
|
||||||
|
|
||||||
var configuredOauths []string
|
var googleConfigured bool
|
||||||
|
var twitterConfigured bool
|
||||||
|
var githubConfigured bool
|
||||||
|
var gitlabConfigured bool
|
||||||
|
|
||||||
func oauthConfigure() error {
|
func oauthConfigure() error {
|
||||||
configuredOauths = []string{}
|
|
||||||
|
|
||||||
if err := googleOauthConfigure(); err != nil {
|
if err := googleOauthConfigure(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := twitterOauthConfigure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := githubOauthConfigure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gitlabOauthConfigure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
43
api/oauth_github.go
Normal file
43
api/oauth_github.go
Normal 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
|
||||||
|
}
|
||||||
129
api/oauth_github_callback.go
Normal file
129
api/oauth_github_callback.go
Normal 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>")
|
||||||
|
}
|
||||||
25
api/oauth_github_redirect.go
Normal file
25
api/oauth_github_redirect.go
Normal 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
42
api/oauth_gitlab.go
Normal 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
|
||||||
|
}
|
||||||
96
api/oauth_gitlab_callback.go
Normal file
96
api/oauth_gitlab_callback.go
Normal 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>")
|
||||||
|
}
|
||||||
25
api/oauth_gitlab_redirect.go
Normal file
25
api/oauth_gitlab_redirect.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ func googleOauthConfigure() error {
|
|||||||
Endpoint: google.Endpoint,
|
Endpoint: google.Endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
configuredOauths = append(configuredOauths, "google")
|
googleConfigured = true
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c, err := commenterGetByEmail("google", user["email"].(string))
|
if user["email"] == nil {
|
||||||
|
fmt.Fprintf(w, "Error: no email address returned by Github")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := user["email"].(string)
|
||||||
|
|
||||||
|
c, err := commenterGetByEmail("google", email)
|
||||||
if err != nil && err != errorNoSuchCommenter {
|
if err != nil && err != errorNoSuchCommenter {
|
||||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||||
return
|
return
|
||||||
@@ -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?
|
// TODO: in case of returning users, update the information we have on record?
|
||||||
if err == errorNoSuchCommenter {
|
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
|
var link string
|
||||||
if val, ok := user["link"]; ok {
|
if val, ok := user["link"]; ok {
|
||||||
link = val.(string)
|
link = val.(string)
|
||||||
|
|||||||
61
api/oauth_sso.go
Normal file
61
api/oauth_sso.go
Normal 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
116
api/oauth_sso_callback.go
Normal 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
88
api/oauth_sso_redirect.go
Normal 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
51
api/oauth_twitter.go
Normal 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
|
||||||
|
}
|
||||||
108
api/oauth_twitter_callback.go
Normal file
108
api/oauth_twitter_callback.go
Normal 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>")
|
||||||
|
}
|
||||||
39
api/oauth_twitter_redirect.go
Normal file
39
api/oauth_twitter_redirect.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -46,3 +46,24 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
|
|||||||
|
|
||||||
return o, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ func ownerNew(email string, name string, password string) (string, error) {
|
|||||||
return "", errorNewOwnerForbidden
|
return "", errorNewOwnerForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := ownerGetByEmail(email); err == nil {
|
||||||
|
return "", errorEmailAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := emailNew(email); err != nil {
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
ownerHex, err := randomHex(32)
|
ownerHex, err := randomHex(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("cannot generate ownerHex: %v", err)
|
logger.Errorf("cannot generate ownerHex: %v", err)
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ func ownerResetPassword(resetHex string, password string) error {
|
|||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
UPDATE owners SET passwordHash=$1
|
UPDATE owners SET passwordHash=$1
|
||||||
WHERE email IN (
|
WHERE ownerHex = (
|
||||||
SELECT email FROM ownerResetHexes
|
SELECT ownerHex
|
||||||
|
FROM ownerResetHexes
|
||||||
WHERE resetHex=$2
|
WHERE resetHex=$2
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ type page struct {
|
|||||||
IsLocked bool `json:"isLocked"`
|
IsLocked bool `json:"isLocked"`
|
||||||
CommentCount int `json:"commentCount"`
|
CommentCount int `json:"commentCount"`
|
||||||
StickyCommentHex string `json:"stickyCommentHex"`
|
StickyCommentHex string `json:"stickyCommentHex"`
|
||||||
|
Title string `json:"title"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ func pageGet(domain string, path string) (page, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT isLocked, commentCount, stickyCommentHex
|
SELECT isLocked, commentCount, stickyCommentHex, title
|
||||||
FROM pages
|
FROM pages
|
||||||
WHERE domain=$1 AND path=$2;
|
WHERE domain=$1 AND path=$2;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, domain, path)
|
row := db.QueryRow(statement, domain, path)
|
||||||
|
|
||||||
p := page{Domain: domain, Path: path}
|
p := page{Domain: domain, Path: path}
|
||||||
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex); err != nil {
|
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex, &p.Title); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
// If there haven't been any comments, there won't be a record for this
|
// If there haven't been any comments, there won't be a record for this
|
||||||
// page. The sane thing to do is return defaults.
|
// page. The sane thing to do is return defaults.
|
||||||
@@ -26,6 +26,7 @@ func pageGet(domain string, path string) (page, error) {
|
|||||||
p.IsLocked = false
|
p.IsLocked = false
|
||||||
p.CommentCount = 0
|
p.CommentCount = 0
|
||||||
p.StickyCommentHex = "none"
|
p.StickyCommentHex = "none"
|
||||||
|
p.Title = ""
|
||||||
} else {
|
} else {
|
||||||
logger.Errorf("error scanning page: %v", err)
|
logger.Errorf("error scanning page: %v", err)
|
||||||
return page{}, errorInternal
|
return page{}, errorInternal
|
||||||
|
|||||||
28
api/page_title.go
Normal file
28
api/page_title.go
Normal 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
|
||||||
|
}
|
||||||
@@ -14,21 +14,42 @@ func apiRouterInit(router *mux.Router) error {
|
|||||||
|
|
||||||
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
|
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST")
|
router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/clear", domainClearHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/sso/new", domainSsoSecretNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST")
|
router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
|
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
|
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
|
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST")
|
router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/import/disqus", domainImportDisqusHandler).Methods("POST")
|
router.HandleFunc("/api/domain/import/disqus", domainImportDisqusHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/export/begin", domainExportBeginHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/export/download", domainExportDownloadHandler).Methods("GET")
|
||||||
|
|
||||||
router.HandleFunc("/api/commenter/token/new", commenterTokenNewHandler).Methods("GET")
|
router.HandleFunc("/api/commenter/token/new", commenterTokenNewHandler).Methods("GET")
|
||||||
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
|
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
|
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
|
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/commenter/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/redirect", googleRedirectHandler).Methods("GET")
|
||||||
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET")
|
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/oauth/github/redirect", githubRedirectHandler).Methods("GET")
|
||||||
|
router.HandleFunc("/api/oauth/github/callback", githubCallbackHandler).Methods("GET")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/oauth/twitter/redirect", twitterRedirectHandler).Methods("GET")
|
||||||
|
router.HandleFunc("/api/oauth/twitter/callback", twitterCallbackHandler).Methods("GET")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/oauth/gitlab/redirect", gitlabRedirectHandler).Methods("GET")
|
||||||
|
router.HandleFunc("/api/oauth/gitlab/callback", gitlabCallbackHandler).Methods("GET")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/oauth/sso/redirect", ssoRedirectHandler).Methods("GET")
|
||||||
|
router.HandleFunc("/api/oauth/sso/callback", ssoCallbackHandler).Methods("GET")
|
||||||
|
|
||||||
router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST")
|
router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
|
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")
|
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func fileDetemplate(f string) ([]byte, error) {
|
|||||||
x = strings.Replace(x, "[[[.Origin]]]", os.Getenv("ORIGIN"), -1)
|
x = strings.Replace(x, "[[[.Origin]]]", os.Getenv("ORIGIN"), -1)
|
||||||
x = strings.Replace(x, "[[[.CdnPrefix]]]", os.Getenv("CDN_PREFIX"), -1)
|
x = strings.Replace(x, "[[[.CdnPrefix]]]", os.Getenv("CDN_PREFIX"), -1)
|
||||||
x = strings.Replace(x, "[[[.Footer]]]", footer, -1)
|
x = strings.Replace(x, "[[[.Footer]]]", footer, -1)
|
||||||
|
x = strings.Replace(x, "[[[.Version]]]", version, -1)
|
||||||
|
|
||||||
return []byte(x), nil
|
return []byte(x), nil
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,7 @@ func staticRouterInit(router *mux.Router) error {
|
|||||||
return err
|
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)
|
files, err := ioutil.ReadDir(os.Getenv("STATIC") + dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("cannot read directory %s%s: %v", os.Getenv("STATIC"), dir, err)
|
logger.Errorf("cannot read directory %s%s: %v", os.Getenv("STATIC"), dir, err)
|
||||||
@@ -98,6 +99,7 @@ func staticRouterInit(router *mux.Router) error {
|
|||||||
"/reset-password",
|
"/reset-password",
|
||||||
"/signup",
|
"/signup",
|
||||||
"/confirm-email",
|
"/confirm-email",
|
||||||
|
"/unsubscribe",
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
"/logout",
|
"/logout",
|
||||||
}
|
}
|
||||||
@@ -115,7 +117,7 @@ func staticRouterInit(router *mux.Router) error {
|
|||||||
if path.Ext(p) != "" {
|
if path.Ext(p) != "" {
|
||||||
contentType[p] = mime.TypeByExtension(path.Ext(p))
|
contentType[p] = mime.TypeByExtension(path.Ext(p))
|
||||||
} else {
|
} else {
|
||||||
contentType[p] = mime.TypeByExtension("html")
|
contentType[p] = "text/html; charset=utf-8"
|
||||||
}
|
}
|
||||||
|
|
||||||
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
|
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func sigintCleanup() int {
|
func sigintCleanup() int {
|
||||||
// TODO: close the database connection and do other cleanup jobs
|
if db != nil {
|
||||||
|
err := db.Close()
|
||||||
|
if err == nil {
|
||||||
|
logger.Errorf("cannot close database connection: %v", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ func smtpConfigure() error {
|
|||||||
password := os.Getenv("SMTP_PASSWORD")
|
password := os.Getenv("SMTP_PASSWORD")
|
||||||
host := os.Getenv("SMTP_HOST")
|
host := os.Getenv("SMTP_HOST")
|
||||||
port := os.Getenv("SMTP_PORT")
|
port := os.Getenv("SMTP_PORT")
|
||||||
if username == "" || password == "" || host == "" || port == "" {
|
if host == "" || port == "" {
|
||||||
logger.Warningf("smtp not configured, no emails will be sent")
|
logger.Warningf("smtp not configured, no emails will be sent")
|
||||||
smtpConfigured = false
|
smtpConfigured = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
logger.Warningf("no SMTP username/password set, Commento will assume they aren't required")
|
||||||
|
}
|
||||||
|
|
||||||
if os.Getenv("SMTP_FROM_ADDRESS") == "" {
|
if os.Getenv("SMTP_FROM_ADDRESS") == "" {
|
||||||
logger.Errorf("COMMENTO_SMTP_FROM_ADDRESS not set")
|
logger.Errorf("COMMENTO_SMTP_FROM_ADDRESS not set")
|
||||||
smtpConfigured = false
|
smtpConfigured = false
|
||||||
|
|||||||
29
api/smtp_domain_export.go
Normal file
29
api/smtp_domain_export.go
Normal 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
|
||||||
|
}
|
||||||
28
api/smtp_domain_export_error.go
Normal file
28
api/smtp_domain_export_error.go
Normal 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
|
||||||
|
}
|
||||||
86
api/smtp_email_notification.go
Normal file
86
api/smtp_email_notification.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"os"
|
"os"
|
||||||
|
"text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
var headerTemplate *template.Template
|
var headerTemplate *template.Template
|
||||||
@@ -20,9 +20,9 @@ var templates map[string]*template.Template
|
|||||||
func smtpTemplatesLoad() error {
|
func smtpTemplatesLoad() error {
|
||||||
var err error
|
var err error
|
||||||
headerTemplate, err = template.New("header").Parse(`MIME-Version: 1.0
|
headerTemplate, err = template.New("header").Parse(`MIME-Version: 1.0
|
||||||
Content-Type: text/html; charset=UTF-8
|
From: Commento <{{.FromAddress}}>
|
||||||
From: {{.FromAddress}}
|
|
||||||
To: {{.ToName}} <{{.ToAddress}}>
|
To: {{.ToName}} <{{.ToAddress}}>
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
Subject: {{.Subject}}
|
Subject: {{.Subject}}
|
||||||
|
|
||||||
`)
|
`)
|
||||||
@@ -31,7 +31,12 @@ Subject: {{.Subject}}
|
|||||||
return errorMalformedTemplate
|
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)
|
templates = make(map[string]*template.Template)
|
||||||
|
|
||||||
@@ -39,9 +44,9 @@ Subject: {{.Subject}}
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
var err error
|
var err error
|
||||||
templates[name] = template.New(name)
|
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 {
|
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
|
return errorMalformedTemplate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
api/utils_html.go
Normal file
40
api/utils_html.go
Normal 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
|
||||||
|
}
|
||||||
@@ -10,6 +10,16 @@ func concat(a bytes.Buffer, b bytes.Buffer) []byte {
|
|||||||
return append(a.Bytes(), b.Bytes()...)
|
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) {
|
func exitIfError(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("fatal error: %v\n", err)
|
fmt.Printf("fatal error: %v\n", err)
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func versionPrint() error {
|
||||||
|
logger.Infof("starting Commento %s", version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func versionCheckStart() error {
|
func versionCheckStart() error {
|
||||||
go func() {
|
go func() {
|
||||||
printedError := false
|
printedError := false
|
||||||
|
|||||||
8
db/20190131002240-export.sql
Normal file
8
db/20190131002240-export.sql
Normal 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
|
||||||
|
);
|
||||||
2
db/20190204180609-v1.5.0.sql
Normal file
2
db/20190204180609-v1.5.0.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE config
|
||||||
|
SET version = 'v1.5.0';
|
||||||
38
db/20190213033530-email-notifications.sql
Normal file
38
db/20190213033530-email-notifications.sql
Normal 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 '';
|
||||||
2
db/20190218173502-v1.6.0.sql
Normal file
2
db/20190218173502-v1.6.0.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE config
|
||||||
|
SET version = 'v1.6.0';
|
||||||
0
db/20190218183556-v1.6.1.sql
Normal file
0
db/20190218183556-v1.6.1.sql
Normal file
2
db/20190219001130-v1.6.2.sql
Normal file
2
db/20190219001130-v1.6.2.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE config
|
||||||
|
SET version = 'v1.6.0';
|
||||||
16
db/20190418210855-configurable-auth.sql
Normal file
16
db/20190418210855-configurable-auth.sql
Normal 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
10
db/20190420181913-sso.sql
Normal 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 '';
|
||||||
6
db/20190420231030-sso-tokens.sql
Normal file
6
db/20190420231030-sso-tokens.sql
Normal 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
|
||||||
|
);
|
||||||
2
db/20190501201032-v1.7.0.sql
Normal file
2
db/20190501201032-v1.7.0.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE config
|
||||||
|
SET version = 'v1.7.0';
|
||||||
@@ -19,12 +19,10 @@ services:
|
|||||||
POSTGRES_DB: commento
|
POSTGRES_DB: commento
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
networks:
|
networks:
|
||||||
- db_network
|
- db_network
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data_volume:/var/lib/postgres
|
- postgres_data_volume:/var/lib/postgresql/data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
db_network:
|
db_network:
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="user-scalable=no, initial-scale=1.0">
|
<meta name="viewport" content="user-scalable=no, initial-scale=1.0">
|
||||||
<script src="[[[.CdnPrefix]]]/js/jquery.js"></script>
|
<script src="[[[.CdnPrefix]]]/js/jquery.js"></script>
|
||||||
|
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
|
||||||
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/auth.css">
|
<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>
|
<title>Commento: Email Confirmation</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<script src="[[[.CdnPrefix]]]/js/highlight.js"></script>
|
<script src="[[[.CdnPrefix]]]/js/highlight.js"></script>
|
||||||
<script src="[[[.CdnPrefix]]]/js/chartist.js"></script>
|
<script src="[[[.CdnPrefix]]]/js/chartist.js"></script>
|
||||||
<script src="[[[.CdnPrefix]]]/js/dashboard.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/chartist.css">
|
||||||
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/dashboard.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>
|
<title>Commento: Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -81,23 +81,29 @@
|
|||||||
<!-- Installation -->
|
<!-- Installation -->
|
||||||
<div id="installation-view" class="view hidden">
|
<div id="installation-view" class="view hidden">
|
||||||
<div class="view-inside">
|
<div class="view-inside">
|
||||||
<div class="large-view">
|
<div class="mid-view">
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<div class="tab">
|
<div class="tab">
|
||||||
<ul class="tabs">
|
<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>
|
</ul>
|
||||||
|
|
||||||
<div id="install-tab-1" class="content original current">
|
<div id="installation-tab-1" class="content original current">
|
||||||
<div class="import-text">
|
<div class="normal-text">
|
||||||
Copy the following piece of HTML code and paste it where you'd like Commento to load.
|
Copy the following piece of HTML code and paste it where you'd like Commento to load.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre><code id="code-div" class="html"></code></pre>
|
<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.
|
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>
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="normal-text">
|
||||||
|
Read the Commento documentation <a href="https://docs.commento.io/configuration/">on configuration</a>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,6 +118,11 @@
|
|||||||
<div class="center center-title">
|
<div class="center center-title">
|
||||||
Analytics
|
Analytics
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="normal-text">
|
||||||
|
Anonymous statistics such as monthly pageviews and number of comments
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="number">
|
<div class="number">
|
||||||
<div class="digits gray-digits">{{domains[cd].viewsLast30Days.zeros}}</div>
|
<div class="digits gray-digits">{{domains[cd].viewsLast30Days.zeros}}</div>
|
||||||
@@ -142,16 +153,67 @@
|
|||||||
<!-- moderation -->
|
<!-- moderation -->
|
||||||
<div id="moderation-view" class="view hidden">
|
<div id="moderation-view" class="view hidden">
|
||||||
<div class="view-inside">
|
<div class="view-inside">
|
||||||
<div class="small-view mid-view">
|
<div class="mid-view">
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<div class="tab">
|
<div class="tab">
|
||||||
<ul class="tabs">
|
<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>
|
</ul>
|
||||||
|
|
||||||
<div id="mod-tab-1" class="content original current">
|
<div id="mod-tab-1" class="content original current">
|
||||||
<div class="pitch">
|
<div class="question">
|
||||||
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="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>
|
||||||
<div class="commento-email-container">
|
<div class="commento-email-container">
|
||||||
<div class="commento-email">
|
<div class="commento-email">
|
||||||
@@ -178,52 +240,103 @@
|
|||||||
<!-- Configure Domain -->
|
<!-- Configure Domain -->
|
||||||
<div id="general-view" class="view hidden">
|
<div id="general-view" class="view hidden">
|
||||||
<div class="view-inside">
|
<div class="view-inside">
|
||||||
<div class="small-mid-view">
|
<div class="mid-view">
|
||||||
<div class="center center-title">
|
<div class="tabs-container">
|
||||||
Configure Domain
|
<div class="tab">
|
||||||
</div>
|
<ul class="tabs">
|
||||||
<div class="box">
|
<li class="tab-link original current" data-tab="configure-tab-1">General</li>
|
||||||
<div class="row">
|
<li class="tab-link" data-tab="configure-tab-2">Export Data</li>
|
||||||
<div class="label">Website Name</div>
|
</ul>
|
||||||
<input class="input gray-input" id="cur-domain-name" type="text" :placeholder="domains[cd].origName" v-model="domains[cd].name">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row no-border round-check">
|
<div id="configure-tab-1" class="content original current">
|
||||||
<input type="checkbox" class="switch" v-model="domains[cd].autoSpamFilter" id="spam-filtering">
|
<div class="box">
|
||||||
<label for="spam-filtering">Automatic spam filtering</label>
|
<div class="row">
|
||||||
<div class="pitch">
|
<div class="label">Website Name</div>
|
||||||
Commento uses Akismet's advanced spam detection to automatically identify and remove spam comments. We strongly recommended you have this enabled.
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +345,7 @@
|
|||||||
<!-- Import Comments -->
|
<!-- Import Comments -->
|
||||||
<div id="import-view" class="view hidden">
|
<div id="import-view" class="view hidden">
|
||||||
<div class="view-inside">
|
<div class="view-inside">
|
||||||
<div class="large-view">
|
<div class="mid-view">
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<div class="tab">
|
<div class="tab">
|
||||||
<ul class="tabs">
|
<ul class="tabs">
|
||||||
@@ -240,15 +353,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div id="install-tab-1" class="content original current">
|
<div id="install-tab-1" class="content original current">
|
||||||
<div class="import-text">
|
<div class="normal-text">
|
||||||
If you're currently using Disqus and want to import all your comments into Commento, you can do so:
|
If you're currently using Disqus, you can import all comments into Commento:
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<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>
|
||||||
|
|
||||||
<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>
|
<br><br>
|
||||||
|
|
||||||
@@ -258,14 +371,23 @@
|
|||||||
<button id="disqus-import-button" class="commento-email-button" onclick="window.commento.importDisqus()">Import</button>
|
<button id="disqus-import-button" class="commento-email-button" onclick="window.commento.importDisqus()">Import</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
<div class="subtext-container">
|
<div class="subtext-container">
|
||||||
<div class="subtext">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<br>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,39 +403,67 @@
|
|||||||
<div id="danger-view" class="view hidden">
|
<div id="danger-view" class="view hidden">
|
||||||
<div class="view-inside">
|
<div class="view-inside">
|
||||||
<div class="mid-view">
|
<div class="mid-view">
|
||||||
<div class="tabs-container">
|
<div class="center center-title">
|
||||||
<div class="tab">
|
Danger Zone
|
||||||
<ul class="tabs">
|
</div>
|
||||||
<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 id="danger-tab-1" class="content original current">
|
<div class="action-buttons-container">
|
||||||
<div class="box" v-if="domains[cd].state == 'frozen'">
|
<div class="action-buttons">
|
||||||
<div class="box-subtitle">
|
<div class="action-button" v-if="domains[cd].state != 'frozen'">
|
||||||
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="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>
|
</div>
|
||||||
|
|
||||||
<button onclick="document.location.hash='#unfreeze-domain-modal'" class="button green-button">Unfreeze Domain</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="right">
|
||||||
<div class="box" v-if="domains[cd].state != 'frozen'">
|
<button onclick="document.location.hash='#freeze-domain-modal'"
|
||||||
<div class="box-subtitle">
|
class="button orange-button">Freeze</button>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="action-button" v-if="domains[cd].state == 'frozen'">
|
||||||
|
<div class="left">
|
||||||
<div id="danger-tab-2" class="content">
|
<div class="title">
|
||||||
<div class="box">
|
Unfreeze Domain
|
||||||
<div class="box-subtitle">
|
</div>
|
||||||
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="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>
|
</div>
|
||||||
|
|
||||||
<button id="big-red-button" class="button big-red-button" onclick="document.location.hash='#delete-domain-modal'">Delete Domain</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,8 +480,8 @@
|
|||||||
<div class="modal-subtitle">
|
<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.
|
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>
|
||||||
<div class="modal-contents">
|
<div class="modal-contents center">
|
||||||
<button id="orange-button" class="button orange-button" onclick="window.commento.domainFreezeHandler()">Freeze Domain</button>
|
<button class="button orange-button" onclick="window.commento.domainFreezeHandler()">Freeze Domain</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,8 +493,21 @@
|
|||||||
<div class="modal-subtitle">
|
<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.
|
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>
|
||||||
<div class="modal-contents">
|
<div class="modal-contents center">
|
||||||
<button id="blue-button" class="button green-button" onclick="window.commento.domainUnfreezeHandler()">Unfreeze Domain</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,8 +519,8 @@
|
|||||||
<div class="modal-subtitle">
|
<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.
|
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>
|
||||||
<div class="modal-contents">
|
<div class="modal-contents center">
|
||||||
<button id="big-red-button" class="button big-red-button" onclick="window.commento.domainDeleteHandler()">Delete Domain</button>
|
<button class="button big-red-button" onclick="window.commento.domainDeleteHandler()">Delete Domain</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
BIN
frontend/fonts/source-sans-300-cyrillic-ext.woff2
Normal file
BIN
frontend/fonts/source-sans-300-cyrillic-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-cyrillic.woff2
Normal file
BIN
frontend/fonts/source-sans-300-cyrillic.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-greek-ext.woff2
Normal file
BIN
frontend/fonts/source-sans-300-greek-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-greek.woff2
Normal file
BIN
frontend/fonts/source-sans-300-greek.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-latin-ext.woff2
Normal file
BIN
frontend/fonts/source-sans-300-latin-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-latin.woff2
Normal file
BIN
frontend/fonts/source-sans-300-latin.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-vietnamese.woff2
Normal file
BIN
frontend/fonts/source-sans-300-vietnamese.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-400-cyrillic-ext.woff2
Normal file
BIN
frontend/fonts/source-sans-400-cyrillic-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-400-cyrillic.woff2
Normal file
BIN
frontend/fonts/source-sans-400-cyrillic.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-400-greek-ext.woff2
Normal file
BIN
frontend/fonts/source-sans-400-greek-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-400-greek.woff2
Normal file
BIN
frontend/fonts/source-sans-400-greek.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user