api: Add go files
I know this is a huge commit, but I can't be bothered to check this in part by part.
This commit is contained in:
parent
60e7e59841
commit
a090770b73
19
api/comment.go
Normal file
19
api/comment.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type comment struct {
|
||||||
|
CommentHex string `json:"commentHex"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Path string `json:"url,omitempty"`
|
||||||
|
CommenterHex string `json:"commenterHex"`
|
||||||
|
Markdown string `json:"markdown"`
|
||||||
|
Html string `json:"html"`
|
||||||
|
ParentHex string `json:"parentHex"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
State string `json:"-"`
|
||||||
|
CreationDate time.Time `json:"creationDate"`
|
||||||
|
VoteDirection int `json:"voteDirection"`
|
||||||
|
}
|
68
api/comment_approve.go
Normal file
68
api/comment_approve.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commentApprove(commentHex string) error {
|
||||||
|
if commentHex == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
UPDATE comments
|
||||||
|
SET state = 'approved'
|
||||||
|
WHERE commentHex = $1;
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := db.Exec(statement, commentHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot approve comment: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentApproveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
CommentHex *string `json:"commentHex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := commenterGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain, err := commentDomainGet(*x.CommentHex)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isModerator, err := isDomainModerator(c.Email, domain)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isModerator {
|
||||||
|
writeBody(w, response{"success": false, "message": errorNotModerator.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = commentApprove(*x.CommentHex); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
33
api/comment_approve_test.go
Normal file
33
api/comment_approve_test.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentApproveBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
commentHex, _ := commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
|
if err := commentApprove(commentHex); err != nil {
|
||||||
|
t.Errorf("unexpected error approving comment: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c, _, _ := commentList("anonymous", "example.com", "/path.html", false); c[0].State != "approved" {
|
||||||
|
t.Errorf("expected state = approved got state = %s", c[0].State)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentApproveEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := commentApprove(""); err == nil {
|
||||||
|
t.Errorf("expected error not found approving comment with empty commentHex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
67
api/comment_delete.go
Normal file
67
api/comment_delete.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commentDelete(commentHex string) error {
|
||||||
|
if commentHex == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
DELETE FROM comments
|
||||||
|
WHERE commentHex=$1;
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, commentHex)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// TODO: make sure this is the error is actually non-existant commentHex
|
||||||
|
return errorNoSuchComment
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
CommentHex *string `json:"commentHex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := commenterGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain, err := commentDomainGet(*x.CommentHex)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isModerator, err := isDomainModerator(c.Email, domain)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isModerator {
|
||||||
|
writeBody(w, response{"success": false, "message": errorNotModerator.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = commentDelete(*x.CommentHex); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
34
api/comment_delete_test.go
Normal file
34
api/comment_delete_test.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentDeleteBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||||
|
commentNew("temp-commenter-hex", "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
|
if err := commentDelete(commentHex); err != nil {
|
||||||
|
t.Errorf("unexpected error deleting comment: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, _, _ := commentList("temp-commenter-hex", "example.com", "/path.html", false)
|
||||||
|
|
||||||
|
if len(c) != 0 {
|
||||||
|
t.Errorf("expected no comments found %d comments", len(c))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentDeleteEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := commentDelete(""); err == nil {
|
||||||
|
t.Errorf("expected error deleting comment with empty commentHex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
24
api/comment_domain_get.go
Normal file
24
api/comment_domain_get.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func commentDomainGet(commentHex string) (string, error) {
|
||||||
|
if commentHex == "" {
|
||||||
|
return "", errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT domain
|
||||||
|
FROM comments
|
||||||
|
WHERE commentHex = $1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, commentHex)
|
||||||
|
|
||||||
|
var domain string
|
||||||
|
var err error
|
||||||
|
if err = row.Scan(&domain); err != nil {
|
||||||
|
return "", errorNoSuchDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain, nil
|
||||||
|
}
|
32
api/comment_domain_get_test.go
Normal file
32
api/comment_domain_get_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentDomainGetBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
|
domain, err := commentDomainGet(commentHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error getting domain by hex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain != "example.com" {
|
||||||
|
t.Errorf("expected domain = example.com got domain = %s", domain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentDomainGetEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commentDomainGet(""); err == nil {
|
||||||
|
t.Errorf("expected error not found getting domain with empty commentHex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
151
api/comment_list.go
Normal file
151
api/comment_list.go
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commentList(commenterHex string, domain string, path string, includeUnapproved bool) ([]comment, map[string]commenter, error) {
|
||||||
|
if commenterHex == "" || domain == "" || path == "" {
|
||||||
|
return nil, nil, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT commentHex, commenterHex, markdown, html, parentHex, score, state, creationDate
|
||||||
|
FROM comments
|
||||||
|
WHERE
|
||||||
|
comments.domain = $1 AND
|
||||||
|
comments.path = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
if !includeUnapproved {
|
||||||
|
if commenterHex == "anonymous" {
|
||||||
|
statement += `
|
||||||
|
AND state = 'approved'
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
statement += `
|
||||||
|
AND (state = 'approved' OR commenterHex = $3)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement += `;`
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if !includeUnapproved && commenterHex != "anonymous" {
|
||||||
|
rows, err = db.Query(statement, domain, path, commenterHex)
|
||||||
|
} else {
|
||||||
|
rows, err = db.Query(statement, domain, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot get comments: %v", err)
|
||||||
|
return nil, nil, errorInternal
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
commenters := make(map[string]commenter)
|
||||||
|
commenters["anonymous"] = commenter{CommenterHex: "anonymous", Email: "undefined", Name: "Anonymous", Link: "undefined", Photo: "undefined", Provider: "undefined"}
|
||||||
|
|
||||||
|
comments := []comment{}
|
||||||
|
for rows.Next() {
|
||||||
|
c := comment{}
|
||||||
|
if err = rows.Scan(&c.CommentHex, &c.CommenterHex, &c.Markdown, &c.Html, &c.ParentHex, &c.Score, &c.State, &c.CreationDate); err != nil {
|
||||||
|
return nil, nil, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if commenterHex != "anonymous" {
|
||||||
|
statement = `
|
||||||
|
SELECT direction
|
||||||
|
FROM votes
|
||||||
|
WHERE commentHex=$1 AND commenterHex=$2;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, c.CommentHex, commenterHex)
|
||||||
|
|
||||||
|
if err = row.Scan(&c.VoteDirection); err != nil {
|
||||||
|
// TODO: is the only error here that there is no such entry?
|
||||||
|
c.VoteDirection = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comments = append(comments, c)
|
||||||
|
|
||||||
|
if _, ok := commenters[c.CommenterHex]; !ok {
|
||||||
|
commenters[c.CommenterHex], err = commenterGetByHex(c.CommenterHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot retrieve commenter: %v", err)
|
||||||
|
return nil, nil, errorInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments, commenters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
Path *string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := stripDomain(*x.Domain)
|
||||||
|
path := *x.Path
|
||||||
|
|
||||||
|
d, err := domainGet(domain)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commenterHex := "anonymous"
|
||||||
|
isModerator := false
|
||||||
|
if *x.Session != "anonymous" {
|
||||||
|
c, err := commenterGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
if err == errorNoSuchSession {
|
||||||
|
commenterHex = "anonymous"
|
||||||
|
} else {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
commenterHex = c.CommenterHex
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mod := range d.Moderators {
|
||||||
|
if mod.Email == c.Email {
|
||||||
|
isModerator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domainViewRecord(domain, commenterHex)
|
||||||
|
|
||||||
|
comments, commenters, err := commentList(commenterHex, domain, path, isModerator)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{
|
||||||
|
"success": true,
|
||||||
|
"domain": domain,
|
||||||
|
"comments": comments,
|
||||||
|
"commenters": commenters,
|
||||||
|
"requireModeration": d.RequireModeration,
|
||||||
|
"requireIdentification": d.RequireIdentification,
|
||||||
|
"isFrozen": d.State == "frozen",
|
||||||
|
"isModerator": isModerator,
|
||||||
|
})
|
||||||
|
}
|
154
api/comment_list_test.go
Normal file
154
api/comment_list_test.go
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentListBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "http://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||||
|
commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
|
c, _, err := commentList("temp-commenter-hex", "example.com", "/path.html", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error listing page comments: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c) != 2 {
|
||||||
|
t.Errorf("expected 2 comments got %d comments", len(c))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c[0].VoteDirection != 0 {
|
||||||
|
t.Errorf("expected c.VoteDirection = 0 got c.VoteDirection = %d", c[0].VoteDirection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c1Html := strings.TrimSpace(c[1].Html)
|
||||||
|
if c1Html != "<p><strong>bar</strong></p>" {
|
||||||
|
t.Errorf("expected c[1].Html=[<p><strong>bar</strong></p>] got c[1].Html=[%s]", c1Html)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, _, err = commentList(commenterHex, "example.com", "/path.html", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error listing page comments: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c) != 2 {
|
||||||
|
t.Errorf("expected 2 comments got %d comments", len(c))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c[0].VoteDirection != 1 {
|
||||||
|
t.Errorf("expected c.VoteDirection = 1 got c.VoteDirection = %d", c[0].VoteDirection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentListEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, _, err := commentList("temp-commenter-hex", "", "/path.html", false); err == nil {
|
||||||
|
t.Errorf("expected error not found listing comments with empty domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentListSelfUnapproved(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "http://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
|
c, _, _ := commentList("temp-commenter-hex", "example.com", "/path.html", false)
|
||||||
|
|
||||||
|
if len(c) != 0 {
|
||||||
|
t.Errorf("expected user to not see unapproved comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, _, _ = commentList(commenterHex, "example.com", "/path.html", false)
|
||||||
|
|
||||||
|
if len(c) != 1 {
|
||||||
|
t.Errorf("expected user to see unapproved self comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentListAnonymousUnapproved(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentNew("anonymous", "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
|
c, _, _ := commentList("anonymous", "example.com", "/path.html", false)
|
||||||
|
|
||||||
|
if len(c) != 0 {
|
||||||
|
t.Errorf("expected user to not see unapproved anonymous comment as anonymous")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentListIncludeUnapproved(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentNew("anonymous", "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
|
c, _, _ := commentList("anonymous", "example.com", "/path.html", true)
|
||||||
|
|
||||||
|
if len(c) != 1 {
|
||||||
|
t.Errorf("expected to see unapproved comments because includeUnapproved was true")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentListDifferentPaths(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentNew("anonymous", "example.com", "/path1.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
commentNew("anonymous", "example.com", "/path1.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
commentNew("anonymous", "example.com", "/path2.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
|
c, _, _ := commentList("anonymous", "example.com", "/path1.html", true)
|
||||||
|
|
||||||
|
if len(c) != 2 {
|
||||||
|
t.Errorf("expected len(c) = 2 got len(c) = %d", len(c))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, _, _ = commentList("anonymous", "example.com", "/path2.html", true)
|
||||||
|
|
||||||
|
if len(c) != 1 {
|
||||||
|
t.Errorf("expected len(c) = 1 got len(c) = %d", len(c))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentListDifferentDomains(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentNew("anonymous", "example1.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
commentNew("anonymous", "example2.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
|
c, _, _ := commentList("anonymous", "example1.com", "/path.html", true)
|
||||||
|
|
||||||
|
if len(c) != 1 {
|
||||||
|
t.Errorf("expected len(c) = 1 got len(c) = %d", len(c))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, _, _ = commentList("anonymous", "example2.com", "/path.html", true)
|
||||||
|
|
||||||
|
if len(c) != 1 {
|
||||||
|
t.Errorf("expected len(c) = 1 got len(c) = %d", len(c))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
120
api/comment_new.go
Normal file
120
api/comment_new.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Take `creationDate` as a param because comment import (from Disqus, for
|
||||||
|
// example) will require a custom time.
|
||||||
|
func commentNew(commenterHex string, domain string, path string, parentHex string, markdown string, state string, creationDate time.Time) (string, error) {
|
||||||
|
// path is allowed to be empty
|
||||||
|
if commenterHex == "" || domain == "" || parentHex == "" || markdown == "" || state == "" {
|
||||||
|
return "", errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
commentHex, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
html := markdownToHtml(markdown)
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
comments (commentHex, domain, path, commenterHex, parentHex, markdown, html, creationDate, state)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, commentHex, domain, path, commenterHex, parentHex, markdown, html, creationDate, state)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot insert comment: %v", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = commentVote(commenterHex, commentHex, 1); err != nil {
|
||||||
|
logger.Warningf("error: cannot upvote new comment automatically: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return commentHex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
Path *string `json:"path"`
|
||||||
|
ParentHex *string `json:"parentHex"`
|
||||||
|
Markdown *string `json:"markdown"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := stripDomain(*x.Domain)
|
||||||
|
path := *x.Path
|
||||||
|
|
||||||
|
d, err := domainGet(domain)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.State == "frozen" {
|
||||||
|
writeBody(w, response{"success": false, "message": errorDomainFrozen.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// logic: (empty column indicates the value doesn't matter)
|
||||||
|
// | anonymous | moderator | requireIdentification | requireModeration | approved? |
|
||||||
|
// |-----------+-----------+-----------------------+-------------------+-----------|
|
||||||
|
// | yes | | | | no |
|
||||||
|
// | no | yes | | | yes |
|
||||||
|
// | no | no | | yes | yes |
|
||||||
|
// | no | no | | no | no |
|
||||||
|
|
||||||
|
var commenterHex string
|
||||||
|
var state string
|
||||||
|
|
||||||
|
if *x.Session == "anonymous" {
|
||||||
|
state = "unapproved"
|
||||||
|
commenterHex = "anonymous"
|
||||||
|
} else {
|
||||||
|
c, err := commenterGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// cheaper than a SQL query as we already have this information
|
||||||
|
isModerator := false
|
||||||
|
for _, mod := range d.Moderators {
|
||||||
|
if mod.Email == c.Email {
|
||||||
|
isModerator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commenterHex = c.CommenterHex
|
||||||
|
|
||||||
|
if isModerator {
|
||||||
|
state = "approved"
|
||||||
|
} else {
|
||||||
|
if d.RequireModeration {
|
||||||
|
state = "unapproved"
|
||||||
|
} else {
|
||||||
|
state = "approved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commentHex, err := commentNew(commenterHex, domain, path, *x.ParentHex, *x.Markdown, state, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true, "commentHex": commentHex})
|
||||||
|
}
|
58
api/comment_new_test.go
Normal file
58
api/comment_new_test.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentNewBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC()); err != nil {
|
||||||
|
t.Errorf("unexpected error creating new comment: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentNewEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commentNew("temp-commenter-hex", "example.com", "", "root", "**foo**", "approved", time.Now().UTC()); err != nil {
|
||||||
|
t.Errorf("empty path not allowed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := commentNew("temp-commenter-hex", "", "", "root", "**foo**", "approved", time.Now().UTC()); err == nil {
|
||||||
|
t.Errorf("expected error not found creatingn new comment with empty domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := commentNew("", "", "", "", "", "", time.Now().UTC()); err == nil {
|
||||||
|
t.Errorf("expected error not found creatingn new comment with empty everything")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentNewUpvoted(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT score
|
||||||
|
FROM comments
|
||||||
|
WHERE commentHex = $1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, commentHex)
|
||||||
|
|
||||||
|
var score int
|
||||||
|
if err := row.Scan(&score); err != nil {
|
||||||
|
t.Errorf("error scanning score from comments table: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if score != 1 {
|
||||||
|
t.Errorf("expected comment to be auto-upvoted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
26
api/comment_ownership_verify.go
Normal file
26
api/comment_ownership_verify.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func commentOwnershipVerify(commenterHex string, commentHex string) (bool, error) {
|
||||||
|
if commenterHex == "" || commentHex == "" {
|
||||||
|
return false, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM comments
|
||||||
|
WHERE commenterHex=$1 AND commentHex=$2
|
||||||
|
);
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, commenterHex, commentHex)
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
if err := row.Scan(&exists); err != nil {
|
||||||
|
logger.Errorf("cannot query if comment owner: %v", err)
|
||||||
|
return false, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return exists, nil
|
||||||
|
}
|
43
api/comment_ownership_verify_test.go
Normal file
43
api/comment_ownership_verify_test.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentOwnershipVerifyBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
|
isOwner, err := commentOwnershipVerify("temp-commenter-hex", commentHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error verifying ownership: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOwner {
|
||||||
|
t.Errorf("expected to be owner of comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner, err = commentOwnershipVerify("another-commenter-hex", commentHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error verifying ownership: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOwner {
|
||||||
|
t.Errorf("unexpected owner of comment not created by another-commenter-hex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentOwnershipVerifyEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commentOwnershipVerify("temp-commenter-hex", ""); err == nil {
|
||||||
|
t.Errorf("expected error not founding verifying ownership with empty commentHex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
66
api/comment_vote.go
Normal file
66
api/comment_vote.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commentVote(commenterHex string, commentHex string, direction int) error {
|
||||||
|
if commentHex == "" || commenterHex == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
votes (commentHex, commenterHex, direction, voteDate)
|
||||||
|
VALUES ($1, $2, $3, $4 )
|
||||||
|
ON CONFLICT (commentHex, commenterHex) DO
|
||||||
|
UPDATE SET direction = $3;
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, commentHex, commenterHex, direction, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("error inserting/updating votes: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentVoteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
CommentHex *string `json:"commentHex"`
|
||||||
|
Direction *int `json:"direction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *x.Session == "anonymous" {
|
||||||
|
writeBody(w, response{"success": false, "message": errorUnauthorisedVote.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := commenterGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
direction := 0
|
||||||
|
if *x.Direction > 0 {
|
||||||
|
direction = 1
|
||||||
|
} else if *x.Direction < 0 {
|
||||||
|
direction = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := commentVote(c.CommenterHex, *x.CommentHex, direction); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
55
api/comment_vote_test.go
Normal file
55
api/comment_vote_test.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentVoteBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
cr0, _ := commenterNew("test1@example.com", "Test1", "undefined", "http://example.com/photo.jpg", "google")
|
||||||
|
cr1, _ := commenterNew("test2@example.com", "Test2", "undefined", "http://example.com/photo.jpg", "google")
|
||||||
|
cr2, _ := commenterNew("test3@example.com", "Test3", "undefined", "http://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
c0, _ := commentNew(cr0, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
|
commentVote(cr0, c0, -1)
|
||||||
|
if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != -1 {
|
||||||
|
t.Errorf("expected c[0].Score = -1 got c[0].Score = %d", c[0].Score)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commentVote(cr1, c0, -1)
|
||||||
|
commentVote(cr2, c0, -1)
|
||||||
|
if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != -3 {
|
||||||
|
t.Errorf("expected c[0].Score = -3 got c[0].Score = %d", c[0].Score)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commentVote(cr1, c0, -1)
|
||||||
|
if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != -3 {
|
||||||
|
t.Errorf("expected c[0].Score = -3 got c[0].Score = %d", c[0].Score)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commentVote(cr1, c0, 0)
|
||||||
|
if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != -2 {
|
||||||
|
t.Errorf("expected c[0].Score = -2 got c[0].Score = %d", c[0].Score)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c1, _ := commentNew(cr1, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
|
commentVote(cr0, c1, 0)
|
||||||
|
if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[1].Score != 1 {
|
||||||
|
t.Errorf("expected c[1].Score = 1 got c[1].Score = %d", c[1].Score)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commentVote(cr1, c1, 0)
|
||||||
|
if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[1].Score != 0 {
|
||||||
|
t.Errorf("expected c[1].Score = 0 got c[1].Score = %d", c[1].Score)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
38
api/commenter.go
Normal file
38
api/commenter.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commenter struct {
|
||||||
|
CommenterHex string `json:"commenterHex,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Photo string `json:"photo"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
JoinDate time.Time `json:"joinDate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func commenterIsProviderUser(provider string, email string) (bool, error) {
|
||||||
|
if provider == "" || email == "" {
|
||||||
|
return false, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM commenters
|
||||||
|
WHERE email=$1 AND provider=$2
|
||||||
|
);
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, email, provider)
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
if err := row.Scan(&exists); err != nil {
|
||||||
|
logger.Errorf("error checking if provider user exists: %v", err)
|
||||||
|
return false, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return exists, nil
|
||||||
|
}
|
45
api/commenter_get.go
Normal file
45
api/commenter_get.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func commenterGetByHex(commenterHex string) (commenter, error) {
|
||||||
|
if commenterHex == "" {
|
||||||
|
return commenter{}, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT commenterHex, email, name, link, photo, provider, joinDate
|
||||||
|
FROM commenters
|
||||||
|
WHERE commenterHex=$1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, commenterHex)
|
||||||
|
|
||||||
|
c := commenter{}
|
||||||
|
if err := row.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil {
|
||||||
|
logger.Errorf("error scanning commenter: %v", err)
|
||||||
|
return commenter{}, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commenterGetBySession(session string) (commenter, error) {
|
||||||
|
if session == "" {
|
||||||
|
return commenter{}, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT commenterHex
|
||||||
|
FROM commenterSessions
|
||||||
|
WHERE session=$1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, session)
|
||||||
|
|
||||||
|
var commenterHex string
|
||||||
|
if err := row.Scan(&commenterHex); err != nil {
|
||||||
|
// TODO: is the only error?
|
||||||
|
return commenter{}, errorNoSuchSession
|
||||||
|
}
|
||||||
|
|
||||||
|
return commenterGetByHex(commenterHex)
|
||||||
|
}
|
61
api/commenter_get_test.go
Normal file
61
api/commenter_get_test.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommenterGetByHexBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
c, err := commenterGetByHex(commenterHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error getting commenter by hex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Name != "Test" {
|
||||||
|
t.Errorf("expected name=Test got name=%s", c.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterGetByHexEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commenterGetByHex(""); err == nil {
|
||||||
|
t.Errorf("expected error not found getting commenter with empty hex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterGetBySession(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
session, _ := commenterSessionNew()
|
||||||
|
|
||||||
|
commenterSessionUpdate(session, commenterHex)
|
||||||
|
|
||||||
|
c, err := commenterGetBySession(session)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error getting commenter by hex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Name != "Test" {
|
||||||
|
t.Errorf("expected name=Test got name=%s", c.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterGetBySessionEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commenterGetBySession(""); err == nil {
|
||||||
|
t.Errorf("expected error not found getting commenter with empty session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
28
api/commenter_new.go
Normal file
28
api/commenter_new.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commenterNew(email string, name string, link string, photo string, provider string) (string, error) {
|
||||||
|
if email == "" || name == "" || link == "" || photo == "" || provider == "" {
|
||||||
|
return "", errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
commenterHex, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
commenters (commenterHex, email, name, link, photo, provider, joinDate)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, commenterHex, email, name, link, photo, provider, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return commenterHex, nil
|
||||||
|
}
|
28
api/commenter_new_test.go
Normal file
28
api/commenter_new_test.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommenterNewBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google"); err != nil {
|
||||||
|
t.Errorf("unexpected error creating new commenter: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterNewEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commenterNew("", "Test", "undefined", "https://example.com/photo.jpg", "google"); err == nil {
|
||||||
|
t.Errorf("expected error not found creating new commenter with empty email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := commenterNew("", "", "", "", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found creating new commenter with empty everything")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
25
api/commenter_self.go
Normal file
25
api/commenter_self.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commenterSelfHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := commenterGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true, "commenter": c})
|
||||||
|
}
|
11
api/commenter_session.go
Normal file
11
api/commenter_session.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commenterSession struct {
|
||||||
|
Session string `json:"session"`
|
||||||
|
CommenterHex string `json:"commenterHex"`
|
||||||
|
CreationDate time.Time `json:"creationDate"`
|
||||||
|
}
|
25
api/commenter_session_get.go
Normal file
25
api/commenter_session_get.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func commenterSessionGet(session string) (commenterSession, error) {
|
||||||
|
if session == "" {
|
||||||
|
return commenterSession{}, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT commenterHex, creationDate
|
||||||
|
FROM commenterSessions
|
||||||
|
WHERE session=$1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, session)
|
||||||
|
|
||||||
|
cs := commenterSession{}
|
||||||
|
if err := row.Scan(&cs.CommenterHex, &cs.CreationDate); err != nil {
|
||||||
|
return commenterSession{}, errorNoSuchSession
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.Session = session
|
||||||
|
|
||||||
|
return cs, nil
|
||||||
|
}
|
46
api/commenter_session_get_test.go
Normal file
46
api/commenter_session_get_test.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommenterSessionGetBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
session, _ := commenterSessionNew()
|
||||||
|
|
||||||
|
commenterSessionUpdate(session, commenterHex)
|
||||||
|
|
||||||
|
cs, err := commenterSessionGet(session)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error found when getting session information: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cs.CommenterHex != commenterHex {
|
||||||
|
t.Errorf("expected commenterHex=%s got commenterHex=%s", commenterHex, cs.CommenterHex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterSessionGetDNE(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
_, err := commenterSessionGet("does-not-exist")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error not found when invalid session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterSessionGetEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
_, err := commenterSessionGet("")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error not found with empty session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
37
api/commenter_session_new.go
Normal file
37
api/commenter_session_new.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commenterSessionNew() (string, error) {
|
||||||
|
session, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot create session hex: %v", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
commenterSessions (session, creationDate)
|
||||||
|
VALUES ($1, $2 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, session, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot insert new session: %v", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commenterSessionNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, err := commenterSessionNew()
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true, "session": session})
|
||||||
|
}
|
14
api/commenter_session_new_test.go
Normal file
14
api/commenter_session_new_test.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommenterSessionNewBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commenterSessionNew(); err != nil {
|
||||||
|
t.Errorf("unexpected error creating new session: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
22
api/commenter_session_update.go
Normal file
22
api/commenter_session_update.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func commenterSessionUpdate(session string, commenterHex string) error {
|
||||||
|
if session == "" || commenterHex == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
UPDATE commenterSessions
|
||||||
|
SET commenterHex=$2
|
||||||
|
WHERE session=$1;
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, session, commenterHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("error updating commenterHex in commenterSessions: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
25
api/commenter_session_update_test.go
Normal file
25
api/commenter_session_update_test.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommenterSessionUpdateBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
session, _ := commenterSessionNew()
|
||||||
|
|
||||||
|
if err := commenterSessionUpdate(session, "temp-commenter-hex"); err != nil {
|
||||||
|
t.Errorf("unexpected error updating session to commenterHex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterSessionUpdateEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := commenterSessionUpdate("", "temp-commenter-hex"); err == nil {
|
||||||
|
t.Errorf("expected error not found when updating with empty session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
42
api/commenter_test.go
Normal file
42
api/commenter_test.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommenterIsProviderUserBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google")
|
||||||
|
|
||||||
|
exists, err := commenterIsProviderUser("google", "test@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error checking if commenter is a provider user: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("user expected to exist not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err = commenterIsProviderUser("google", "test2@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error checking if commenter is a provider user: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
t.Errorf("user expected to not exist not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommenterIsProviderUserEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := commenterIsProviderUser("google", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when checking for user with empty email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
40
api/config.go
Normal file
40
api/config.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseConfig() error {
|
||||||
|
defaults := map[string]string{
|
||||||
|
"POSTGRES": "postgres://postgres:postgres@0.0.0.0/commento?sslmode=disable",
|
||||||
|
|
||||||
|
"PORT": "8080",
|
||||||
|
"ORIGIN": "",
|
||||||
|
|
||||||
|
"CDN_PREFIX": "",
|
||||||
|
|
||||||
|
"SMTP_USERNAME": "",
|
||||||
|
"SMTP_PASSWORD": "",
|
||||||
|
"SMTP_HOST": "",
|
||||||
|
"SMTP_FROM_ADDRESS": "",
|
||||||
|
|
||||||
|
"OAUTH_GOOGLE_KEY": "",
|
||||||
|
"OAUTH_GOOGLE_SECRET": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range defaults {
|
||||||
|
if os.Getenv(key) == "" {
|
||||||
|
os.Setenv(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mandatory config parameters
|
||||||
|
for _, env := range []string{"POSTGRES", "PORT", "ORIGIN"} {
|
||||||
|
if os.Getenv(env) == "" {
|
||||||
|
logger.Fatalf("missing %s environment variable", env)
|
||||||
|
return errorMissingConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
33
api/config_test.go
Normal file
33
api/config_test.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseConfigBasics(t *testing.T) {
|
||||||
|
os.Setenv("ORIGIN", "https://commento.io")
|
||||||
|
|
||||||
|
if err := parseConfig(); err != nil {
|
||||||
|
t.Errorf("unexpected error when parsing config: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test feels kinda stupid, but whatever.
|
||||||
|
if os.Getenv("PORT") != "8080" {
|
||||||
|
t.Errorf("expected PORT=8080, but PORT=%s instead", os.Getenv("PORT"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("PORT", "1886")
|
||||||
|
|
||||||
|
if err := parseConfig(); err != nil {
|
||||||
|
t.Errorf("unexpected error when parsing config: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("PORT") != "1886" {
|
||||||
|
t.Errorf("expected PORT=1886, but PORT=%s instead", os.Getenv("PORT"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
7
api/database.go
Normal file
7
api/database.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
39
api/database_connect.go
Normal file
39
api/database_connect.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func connectDB() error {
|
||||||
|
con := os.Getenv("POSTGRES")
|
||||||
|
logger.Infof("opening connection to postgres: %s", con)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("postgres", con)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot open connection to postgres: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
filename TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot create migrations table: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// At most 1000 database connections will be left open in the idle state. This
|
||||||
|
// was found to be important when benchmarking with `wrk`: if this was unset,
|
||||||
|
// too many open idle connections were present, resulting in dropped requests
|
||||||
|
// due to the limit on the number of file handles. On benchmarking, around
|
||||||
|
// 100 was found to be pretty optimal.
|
||||||
|
db.SetMaxIdleConns(100)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
80
api/database_migrations.go
Normal file
80
api/database_migrations.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func performMigrations() error {
|
||||||
|
return performMigrationsFromDir("db")
|
||||||
|
}
|
||||||
|
|
||||||
|
func performMigrationsFromDir(dir string) error {
|
||||||
|
files, err := ioutil.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot read directory for migrations: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT filename
|
||||||
|
FROM migrations;
|
||||||
|
`
|
||||||
|
rows, err := db.Query(statement)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot query migrations: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
filenames := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var filename string
|
||||||
|
if err = rows.Scan(&filename); err != nil {
|
||||||
|
logger.Errorf("cannot scan filename: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filenames[filename] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
completed := 0
|
||||||
|
for _, file := range files {
|
||||||
|
if strings.HasSuffix(file.Name(), ".sql") {
|
||||||
|
if !filenames[file.Name()] {
|
||||||
|
f := dir + string(os.PathSeparator) + file.Name()
|
||||||
|
contents, err := ioutil.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot read file %s: %v", file.Name(), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = db.Exec(string(contents)); err != nil {
|
||||||
|
logger.Errorf("cannot execute the SQL in %s: %v", f, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
INSERT INTO
|
||||||
|
migrations (filename)
|
||||||
|
VALUES ($1 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, file.Name())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot insert filename into the migrations table: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if completed > 0 {
|
||||||
|
logger.Infof("%d migrations found, %d new migrations completed (%d total)", len(filenames), completed, len(filenames)+completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
18
api/domain.go
Normal file
18
api/domain.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type domain struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
OwnerHex string `json:"ownerHex"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreationDate time.Time `json:"creationDate"`
|
||||||
|
State string `json:"state"`
|
||||||
|
ImportedComments bool `json:"importedComments"`
|
||||||
|
AutoSpamFilter bool `json:"autoSpamFilter"`
|
||||||
|
RequireModeration bool `json:"requireModeration"`
|
||||||
|
RequireIdentification bool `json:"requireIdentification"`
|
||||||
|
Moderators []moderator `json:"moderators"`
|
||||||
|
}
|
52
api/domain_delete.go
Normal file
52
api/domain_delete.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func domainDelete(domain string) error {
|
||||||
|
if domain == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
DELETE FROM
|
||||||
|
domains
|
||||||
|
WHERE domain = $1;
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, domain)
|
||||||
|
if err != nil {
|
||||||
|
return errorNoSuchDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
DELETE FROM votes
|
||||||
|
USING comments
|
||||||
|
WHERE comments.commentHex = votes.commentHex AND comments.domain = $1;
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, domain)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot delete votes: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
DELETE FROM views
|
||||||
|
WHERE views.domain = $1;
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, domain)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot delete views: %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
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
38
api/domain_delete_test.go
Normal file
38
api/domain_delete_test.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainDeleteBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainNew("temp-owner-hex", "Example", "example.com")
|
||||||
|
domainNew("temp-owner-hex", "Example", "example2.com")
|
||||||
|
|
||||||
|
if err := domainDelete("example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error deleting domain: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d, _ := domainList("temp-owner-hex")
|
||||||
|
|
||||||
|
if len(d) != 1 {
|
||||||
|
t.Errorf("expected number of domains to be 1 got %d", len(d))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d[0].Domain != "example2.com" {
|
||||||
|
t.Errorf("expected first domain to be example2.com got %s", d[0].Domain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainDeleteEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := domainDelete(""); err == nil {
|
||||||
|
t.Errorf("expected error not found when deleting with empty domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
29
api/domain_get.go
Normal file
29
api/domain_get.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func domainGet(dmn string) (domain, error) {
|
||||||
|
if dmn == "" {
|
||||||
|
return domain{}, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification
|
||||||
|
FROM domains
|
||||||
|
WHERE domain = $1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, dmn)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
d := domain{}
|
||||||
|
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification); err != nil {
|
||||||
|
return d, errorNoSuchDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Moderators, err = domainModeratorList(d.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return domain{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
40
api/domain_get_test.go
Normal file
40
api/domain_get_test.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainGetBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainNew("temp-owner-hex", "Example", "example.com")
|
||||||
|
|
||||||
|
d, err := domainGet("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error getting domain: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Name != "Example" {
|
||||||
|
t.Errorf("expected name=Example got name=%s", d.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainGetEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := domainGet(""); err == nil {
|
||||||
|
t.Errorf("expected error not found when getting with empty domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainGetDNE(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := domainGet("example.com"); err == nil {
|
||||||
|
t.Errorf("expected error not found when getting non-existant domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
67
api/domain_list.go
Normal file
67
api/domain_list.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func domainList(ownerHex string) ([]domain, error) {
|
||||||
|
if ownerHex == "" {
|
||||||
|
return []domain{}, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification
|
||||||
|
FROM domains
|
||||||
|
WHERE ownerHex=$1;
|
||||||
|
`
|
||||||
|
rows, err := db.Query(statement, ownerHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot query domains: %v", err)
|
||||||
|
return nil, errorInternal
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
domains := []domain{}
|
||||||
|
for rows.Next() {
|
||||||
|
d := domain{}
|
||||||
|
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification); err != nil {
|
||||||
|
logger.Errorf("cannot Scan domain: %v", err)
|
||||||
|
return nil, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Moderators, err = domainModeratorList(d.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return []domain{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
domains = append(domains, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domains, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainListHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := ownerGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domains, err := domainList(o.OwnerHex)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true, "domains": domains})
|
||||||
|
}
|
33
api/domain_list_test.go
Normal file
33
api/domain_list_test.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainListBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainNew("temp-owner-hex", "Example", "example.com")
|
||||||
|
domainNew("temp-owner-hex", "Example", "example2.com")
|
||||||
|
|
||||||
|
d, err := domainList("temp-owner-hex")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error listing domains: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(d) != 2 {
|
||||||
|
t.Errorf("expected number of domains to be 2 got %d", len(d))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d[0].Domain != "example.com" {
|
||||||
|
t.Errorf("expected first domain to be example.com got %s", d[0].Domain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d[1].Domain != "example2.com" {
|
||||||
|
t.Errorf("expected first domain to be example2.com got %s", d[1].Domain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
57
api/domain_moderator.go
Normal file
57
api/domain_moderator.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type moderator struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
AddDate time.Time `json:"addDate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainModeratorList(domain string) ([]moderator, error) {
|
||||||
|
statement := `
|
||||||
|
SELECT email, addDate
|
||||||
|
FROM moderators
|
||||||
|
WHERE domain=$1;
|
||||||
|
`
|
||||||
|
rows, err := db.Query(statement, domain)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot get moderators: %v", err)
|
||||||
|
return nil, errorInternal
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
moderators := []moderator{}
|
||||||
|
for rows.Next() {
|
||||||
|
m := moderator{}
|
||||||
|
if err = rows.Scan(&m.Email, &m.AddDate); err != nil {
|
||||||
|
logger.Errorf("cannot Scan moderator: %v", err)
|
||||||
|
return nil, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
moderators = append(moderators, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return moderators, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDomainModerator(domain string, email string) (bool, error) {
|
||||||
|
statement := `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM moderators
|
||||||
|
WHERE domain=$1 AND email=$2
|
||||||
|
);
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, domain, email)
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
if err := row.Scan(&exists); err != nil {
|
||||||
|
logger.Errorf("cannot query if moderator: %v", err)
|
||||||
|
return false, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return exists, nil
|
||||||
|
}
|
62
api/domain_moderator_delete.go
Normal file
62
api/domain_moderator_delete.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func domainModeratorDelete(domain string, email string) error {
|
||||||
|
if domain == "" || email == "" {
|
||||||
|
return errorMissingConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
DELETE FROM moderators
|
||||||
|
WHERE domain=$1 AND email=$2;
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, domain, email)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot delete moderator: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainModeratorDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := ownerGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := stripDomain(*x.Domain)
|
||||||
|
authorised, err := domainOwnershipVerify(domain, o.Email)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorised {
|
||||||
|
writeBody(w, response{"success": false, "message": errorNotAuthorised.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = domainModeratorDelete(domain, *x.Email); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
45
api/domain_moderator_delete_test.go
Normal file
45
api/domain_moderator_delete_test.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainModeratorDeleteBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainModeratorNew("example.com", "test@example.com")
|
||||||
|
domainModeratorNew("example.com", "test2@example.com")
|
||||||
|
|
||||||
|
if err := domainModeratorDelete("example.com", "test@example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error creating new domain moderator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMod, _ := isDomainModerator("example.com", "test@example.com")
|
||||||
|
if isMod {
|
||||||
|
t.Errorf("email %s still moderator after deletion", "test@example.com")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMod, _ = isDomainModerator("example.com", "test2@example.com")
|
||||||
|
if !isMod {
|
||||||
|
t.Errorf("email %s no longer moderator after deleting a different email", "test@example.com")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainModeratorDeleteEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainModeratorNew("example.com", "test@example.com")
|
||||||
|
|
||||||
|
if err := domainModeratorDelete("example.com", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when passing empty email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := domainModeratorDelete("", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when passing empty everything")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
64
api/domain_moderator_new.go
Normal file
64
api/domain_moderator_new.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func domainModeratorNew(domain string, email string) error {
|
||||||
|
if domain == "" || email == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
moderators (domain, email, addDate)
|
||||||
|
VALUES ($1, $2, $3 );
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, domain, email, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot insert new moderator: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainModeratorNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := ownerGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := stripDomain(*x.Domain)
|
||||||
|
isOwner, err := domainOwnershipVerify(o.OwnerHex, domain)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOwner {
|
||||||
|
writeBody(w, response{"success": false, "message": errorNotAuthorised.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = domainModeratorNew(domain, *x.Email); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
28
api/domain_moderator_new_test.go
Normal file
28
api/domain_moderator_new_test.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainModeratorNewBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := domainModeratorNew("example.com", "test@example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error creating new domain moderator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainModeratorNewEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := domainModeratorNew("example.com", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when creating new moderator with empty email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := domainModeratorNew("", "test@example.com"); err == nil {
|
||||||
|
t.Errorf("expected error not found when creating new moderator with empty domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
61
api/domain_moderator_test.go
Normal file
61
api/domain_moderator_test.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainModeratorListBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainModeratorNew("example.com", "test@example.com")
|
||||||
|
domainModeratorNew("example.com", "test2@example.com")
|
||||||
|
|
||||||
|
mods, err := domainModeratorList("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error listing domain moderators: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mods) != 2 {
|
||||||
|
t.Errorf("expected number of domain moderators to be 2 got %d", len(mods))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mods[0].Email != "test@example.com" {
|
||||||
|
t.Errorf("expected first domain to be test@example.com got %s", mods[0].Email)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mods[1].Email != "test2@example.com" {
|
||||||
|
t.Errorf("expected first domain to be test2@example.com got %s", mods[0].Email)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDomainModeratorBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainModeratorNew("example.com", "test@example.com")
|
||||||
|
|
||||||
|
isMod, err := isDomainModerator("example.com", "test@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error checking if email is a moderator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMod {
|
||||||
|
t.Errorf("expected test@example.com to be a moderator got isMod=false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMod, err = isDomainModerator("example.com", "test2@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error checking if email is a moderator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMod {
|
||||||
|
t.Errorf("expected test2@example.com to not be a moderator got isMod=true")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
59
api/domain_new.go
Normal file
59
api/domain_new.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func domainNew(ownerHex string, name string, domain string) error {
|
||||||
|
if ownerHex == "" || name == "" || domain == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
domains (ownerHex, name, domain, creationDate)
|
||||||
|
VALUES ($1, $2, $3, $4 );
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, ownerHex, name, domain, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Make sure this is really the error.
|
||||||
|
return errorDomainAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := ownerGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := stripDomain(*x.Domain)
|
||||||
|
|
||||||
|
if err = domainNew(o.Email, *x.Name, domain); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = domainModeratorNew(*x.Domain, o.Email); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true, "domain": domain})
|
||||||
|
}
|
42
api/domain_new_test.go
Normal file
42
api/domain_new_test.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainNewBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := domainNew("temp-owner-hex", "Example", "example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error creating domain: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainNewClash(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := domainNew("temp-owner-hex", "Example", "example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error creating domain: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := domainNew("temp-owner-hex", "Example 2", "example.com"); err == nil {
|
||||||
|
t.Errorf("expected error not found when creating with clashing domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainNewEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if err := domainNew("temp-owner-hex", "Example", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when creating with emtpy domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := domainNew("", "", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when creating with emtpy everything")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
26
api/domain_ownership_verify.go
Normal file
26
api/domain_ownership_verify.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func domainOwnershipVerify(ownerHex string, domain string) (bool, error) {
|
||||||
|
if ownerHex == "" || domain == "" {
|
||||||
|
return false, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM domains
|
||||||
|
WHERE ownerHex=$1 AND domain=$2
|
||||||
|
);
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, ownerHex, domain)
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
if err := row.Scan(&exists); err != nil {
|
||||||
|
logger.Errorf("cannot query if domain owner: %v", err)
|
||||||
|
return false, errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return exists, nil
|
||||||
|
}
|
39
api/domain_ownership_verify_test.go
Normal file
39
api/domain_ownership_verify_test.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainVerifyOwnershipBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
ownerLogin("test@example.com", "hunter2")
|
||||||
|
|
||||||
|
domainNew(ownerHex, "Example", "example.com")
|
||||||
|
|
||||||
|
isOwner, err := domainOwnershipVerify(ownerHex, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error checking ownership: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOwner {
|
||||||
|
t.Errorf("expected isOwner=true got isOwner=false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
otherOwnerHex, _ := ownerNew("test2@example.com", "Test2", "hunter2")
|
||||||
|
ownerLogin("test2@example.com", "hunter2")
|
||||||
|
|
||||||
|
isOwner, err = domainOwnershipVerify(otherOwnerHex, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error checking ownership: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOwner {
|
||||||
|
t.Errorf("expected isOwner=false got isOwner=true")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
59
api/domain_update.go
Normal file
59
api/domain_update.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func domainUpdate(d domain) error {
|
||||||
|
statement := `
|
||||||
|
UPDATE domains
|
||||||
|
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6
|
||||||
|
WHERE domain=$1;
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot update non-moderators: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
D *domain `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := ownerGetBySession(*x.Session)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := stripDomain((*x.D).Domain)
|
||||||
|
isOwner, err := domainOwnershipVerify(o.OwnerHex, domain)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOwner {
|
||||||
|
writeBody(w, response{"success": false, "message": errorNotAuthorised.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = domainUpdate(*x.D); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
27
api/domain_update_test.go
Normal file
27
api/domain_update_test.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainUpdateBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
domainNew("temp-owner-hex", "Example", "example.com")
|
||||||
|
|
||||||
|
d, _ := domainList("temp-owner-hex")
|
||||||
|
|
||||||
|
d[0].Name = "Example2"
|
||||||
|
|
||||||
|
if err := domainUpdate(d[0]); err != nil {
|
||||||
|
t.Errorf("unexpected error updating domain: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d, _ = domainList("temp-owner-hex")
|
||||||
|
|
||||||
|
if d[0].Name != "Example2" {
|
||||||
|
t.Errorf("expected name=Example2 got name=%s", d[0].Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
17
api/domain_view_record.go
Normal file
17
api/domain_view_record.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func domainViewRecord(domain string, commenterHex string) {
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
views (domain, commenterHex, viewDate)
|
||||||
|
VALUES ($1, $2, $3 );
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, domain, commenterHex, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("cannot insert views: %v", err)
|
||||||
|
}
|
||||||
|
}
|
44
api/errors.go
Normal file
44
api/errors.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errorMalformedTemplate = errors.New("A template is malformed.")
|
||||||
|
var errorMissingConfig = errors.New("Missing config environment variable.")
|
||||||
|
var errorCannotSendEmail = errors.New("Email dispatch failed. Please contact support to resolve this issue.")
|
||||||
|
var errorInternal = errors.New("An internal error has occurred. If you see this repeatedly, please contact support.")
|
||||||
|
var errorInvalidJSONBody = errors.New("Invalid JSON request. If you think this shouldn't happen, please contact support.")
|
||||||
|
var errorMissingField = errors.New("One or more field(s) empty.")
|
||||||
|
var errorEmailExists = errors.New("That email address is already registered. Sign in instead?")
|
||||||
|
var errorInvalidEmailPassword = errors.New("Invalid email/password combination.")
|
||||||
|
var errorUnconfirmedEmail = errors.New("Your email address is still unconfirmed. Please confirm your email address before proceeding.")
|
||||||
|
var errorNoSuchConfirmationToken = errors.New("This email confirmation link has expired.")
|
||||||
|
var errorNoSuchResetToken = errors.New("This password reset link has expired.")
|
||||||
|
var errorNotAuthorised = errors.New("You're not authorised to access that.")
|
||||||
|
var errorEmailAlreadyExists = errors.New("That email address has already been registered.")
|
||||||
|
var errorNoSuchSession = errors.New("No such session/state.")
|
||||||
|
var errorAlreadyUpvoted = errors.New("You have already upvoted that comment.")
|
||||||
|
var errorNoSuchDomain = errors.New("This domain is not registered with Commento.")
|
||||||
|
var errorNoSuchComment = errors.New("No such comment.")
|
||||||
|
var errorNeedPro = errors.New("You need to have a pro/business account to do that.")
|
||||||
|
var errorInvalidState = errors.New("Invalid state value.")
|
||||||
|
var errorInvalidTrial = errors.New("Invalid trial value.")
|
||||||
|
var errorDomainFrozen = errors.New("Cannot add a new comment because that domain is frozen.")
|
||||||
|
var errorDomainAlreadyExists = errors.New("That domain has already been registered. Please contact support if you are the true owner.")
|
||||||
|
var errorUnauthorisedVote = errors.New("You need to be authenticated to vote.")
|
||||||
|
var errorNoSuchEmail = errors.New("No such email.")
|
||||||
|
var errorInvalidEmail = errors.New("You do not have an email registered with that account.")
|
||||||
|
var errorNoTrialChange = errors.New("You cannot change to a trial plan.")
|
||||||
|
var errorInvalidPlan = errors.New("Invalid plan value.")
|
||||||
|
var errorNoSource = errors.New("You have no payment source on record to change your plan.")
|
||||||
|
var errorCannotDowngrage = errors.New("Cannot downgrade plan features.")
|
||||||
|
var errorForbiddenEdit = errors.New("You cannot edit someone else's comment.")
|
||||||
|
var errorNotInvited = errors.New("Commento is currently in private beta and invite-only for now.")
|
||||||
|
var errorMissingSmtpAddress = errors.New("Missing SMTP_FROM_ADDRESS")
|
||||||
|
var errorSmtpNotConfigured = errors.New("SMTP is not configured.")
|
||||||
|
var errorOauthMisconfigured = errors.New("OAuth is misconfigured.")
|
||||||
|
var errorUnassociatedSession = errors.New("No user associated with that session.")
|
||||||
|
var errorSessionAlreadyInUse = errors.New("Session is already in use.")
|
||||||
|
var errorCannotReadResponse = errors.New("Cannot read response.")
|
||||||
|
var errorNotModerator = errors.New("You need to be a moderator to do that.")
|
13
api/main.go
Normal file
13
api/main.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
exitIfError(createLogger())
|
||||||
|
exitIfError(parseConfig())
|
||||||
|
exitIfError(connectDB())
|
||||||
|
exitIfError(performMigrations())
|
||||||
|
exitIfError(smtpConfigure())
|
||||||
|
exitIfError(oauthConfigure())
|
||||||
|
exitIfError(createMarkdownRenderer())
|
||||||
|
|
||||||
|
exitIfError(serveRoutes())
|
||||||
|
}
|
28
api/markdown.go
Normal file
28
api/markdown.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
"gopkg.in/russross/blackfriday.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var policy *bluemonday.Policy
|
||||||
|
var renderer blackfriday.Renderer
|
||||||
|
var extensions int
|
||||||
|
|
||||||
|
func createMarkdownRenderer() error {
|
||||||
|
policy = bluemonday.UGCPolicy()
|
||||||
|
|
||||||
|
extensions = 0
|
||||||
|
extensions |= blackfriday.EXTENSION_AUTOLINK
|
||||||
|
extensions |= blackfriday.EXTENSION_STRIKETHROUGH
|
||||||
|
|
||||||
|
htmlFlags := 0
|
||||||
|
htmlFlags |= blackfriday.HTML_SKIP_HTML
|
||||||
|
htmlFlags |= blackfriday.HTML_SKIP_IMAGES
|
||||||
|
htmlFlags |= blackfriday.HTML_SAFELINK
|
||||||
|
htmlFlags |= blackfriday.HTML_HREF_TARGET_BLANK
|
||||||
|
|
||||||
|
renderer = blackfriday.HtmlRenderer(htmlFlags, "", "")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
10
api/markdown_html.go
Normal file
10
api/markdown_html.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gopkg.in/russross/blackfriday.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func markdownToHtml(markdown string) string {
|
||||||
|
unsafe := blackfriday.Markdown([]byte(markdown), renderer, extensions)
|
||||||
|
return string(policy.SanitizeBytes(unsafe))
|
||||||
|
}
|
39
api/markdown_html_test.go
Normal file
39
api/markdown_html_test.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarkdownToHtmlBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
// basic markdown and expected html tests
|
||||||
|
tests := map[string]string{
|
||||||
|
"Foo": "<p>Foo</p>",
|
||||||
|
|
||||||
|
"Foo\n\nBar": "<p>Foo</p>\n\n<p>Bar</p>",
|
||||||
|
|
||||||
|
"XSS: <script src='http://example.com/script.js'></script> Foo": "<p>XSS: Foo</p>",
|
||||||
|
|
||||||
|
"Regular [Link](http://example.com)": "<p>Regular <a href=\"http://example.com\" rel=\"nofollow\">Link</a></p>",
|
||||||
|
|
||||||
|
"XSS [Link](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pgo=)": "<p>XSS <tt>Link</tt></p>",
|
||||||
|
|
||||||
|
"![Images disallowed](http://example.com/image.jpg)": "<p></p>",
|
||||||
|
|
||||||
|
"**bold** *italics*": "<p><strong>bold</strong> <em>italics</em></p>",
|
||||||
|
|
||||||
|
"http://example.com/autolink": "<p><a href=\"http://example.com/autolink\" rel=\"nofollow\">http://example.com/autolink</a></p>",
|
||||||
|
|
||||||
|
"<b>not bold</b>": "<p>not bold</p>",
|
||||||
|
}
|
||||||
|
|
||||||
|
for in, out := range tests {
|
||||||
|
html := strings.TrimSpace(markdownToHtml(in))
|
||||||
|
if html != out {
|
||||||
|
t.Errorf("for in=[%s] expected out=[%s] got out=[%s]", in, out, html)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
api/oauth.go
Normal file
11
api/oauth.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func oauthConfigure() error {
|
||||||
|
if err := googleOauthConfigure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
41
api/oauth_google.go
Normal file
41
api/oauth_google.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var googleConfig *oauth2.Config
|
||||||
|
|
||||||
|
func googleOauthConfigure() error {
|
||||||
|
googleConfig = nil
|
||||||
|
if os.Getenv("GOOGLE_KEY") == "" && os.Getenv("GOOGLE_SECRET") == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("GOOGLE_KEY") == "" {
|
||||||
|
logger.Errorf("GOOGLE_KEY not configured, but GOOGLE_SECRET is set")
|
||||||
|
return errorOauthMisconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("GOOGLE_SECRET") == "" {
|
||||||
|
logger.Errorf("GOOGLE_SECRET not configured, but GOOGLE_KEY is set")
|
||||||
|
return errorOauthMisconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("loading Google OAuth config")
|
||||||
|
|
||||||
|
googleConfig = &oauth2.Config{
|
||||||
|
RedirectURL: os.Getenv("BACKEND_WEB") + "/oauth/google/callback",
|
||||||
|
ClientID: os.Getenv("GOOGLE_KEY"),
|
||||||
|
ClientSecret: os.Getenv("GOOGLE_SECRET"),
|
||||||
|
Scopes: []string{
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
85
api/oauth_google_callback.go
Normal file
85
api/oauth_google_callback.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := r.FormValue("state")
|
||||||
|
code := r.FormValue("code")
|
||||||
|
|
||||||
|
cs, err := commenterSessionGet(session)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cs.Session != "none" {
|
||||||
|
fmt.Fprintf(w, "Error: %v", errorSessionAlreadyInUse.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := googleConfig.Exchange(oauth2.NoContext, code)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + token.AccessToken)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := commenterIsProviderUser("google", user["email"].(string))
|
||||||
|
if err != nil {
|
||||||
|
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 !exists {
|
||||||
|
var email string
|
||||||
|
if _, ok := user["email"]; ok {
|
||||||
|
email = user["email"].(string)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "error: %s", errorInvalidEmail.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var link string
|
||||||
|
if val, ok := user["link"]; ok {
|
||||||
|
link = val.(string)
|
||||||
|
} else {
|
||||||
|
link = "undefined"
|
||||||
|
}
|
||||||
|
|
||||||
|
commenterHex, err = commenterNew(email, user["name"].(string), link, user["picture"].(string), "google")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := commenterSessionUpdate(session, commenterHex); err != nil {
|
||||||
|
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
|
||||||
|
}
|
24
api/oauth_google_redirect.go
Normal file
24
api/oauth_google_redirect.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func googleRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := r.FormValue("session")
|
||||||
|
|
||||||
|
c, err := commenterGetBySession(session)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "error: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CommenterHex != "none" {
|
||||||
|
fmt.Fprintf(w, "error: that session is already in use\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := googleConfig.AuthCodeURL(session)
|
||||||
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
|
}
|
59
api/oauth_google_test.go
Normal file
59
api/oauth_google_test.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetGoogleVars() {
|
||||||
|
for _, env := range []string{"GOOGLE_KEY", "GOOGLE_SECRET"} {
|
||||||
|
os.Setenv(env, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleOauthConfigureBasics(t *testing.T) {
|
||||||
|
resetGoogleVars()
|
||||||
|
|
||||||
|
os.Setenv("GOOGLE_KEY", "google-key")
|
||||||
|
os.Setenv("GOOGLE_SECRET", "google-secret")
|
||||||
|
|
||||||
|
if err := googleOauthConfigure(); err != nil {
|
||||||
|
t.Errorf("unexpected error configuring google oauth: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if googleConfig == nil {
|
||||||
|
t.Errorf("expected googleConfig!=nil got googleConfig=nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleOauthConfigureEmpty(t *testing.T) {
|
||||||
|
resetGoogleVars()
|
||||||
|
|
||||||
|
os.Setenv("GOOGLE_KEY", "google-key")
|
||||||
|
|
||||||
|
if err := googleOauthConfigure(); err == nil {
|
||||||
|
t.Errorf("expected error not found when configuring google oauth with empty GOOGLE_SECRET")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if googleConfig != nil {
|
||||||
|
t.Errorf("expected googleConfig=nil got googleConfig=%v", googleConfig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleOauthConfigureEmpty2(t *testing.T) {
|
||||||
|
resetGoogleVars()
|
||||||
|
|
||||||
|
if err := googleOauthConfigure(); err != nil {
|
||||||
|
t.Errorf("unexpected error configuring google oauth with empty everything: should be disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if googleConfig != nil {
|
||||||
|
t.Errorf("expected googleConfig=nil got googleConfig=%v", googleConfig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
13
api/owner.go
Normal file
13
api/owner.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type owner struct {
|
||||||
|
OwnerHex string `json:"ownerHex"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ConfirmedEmail bool `json:"confirmedEmail"`
|
||||||
|
JoinDate time.Time `json:"joinDate"`
|
||||||
|
}
|
61
api/owner_confirm_hex.go
Normal file
61
api/owner_confirm_hex.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownerConfirmHex(confirmHex string) error {
|
||||||
|
if confirmHex == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
UPDATE owners
|
||||||
|
SET confirmedEmail=true
|
||||||
|
WHERE ownerHex IN (
|
||||||
|
SELECT ownerHex FROM ownerConfirmHexes
|
||||||
|
WHERE confirmHex=$1
|
||||||
|
);
|
||||||
|
`
|
||||||
|
res, err := db.Exec(statement, confirmHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot mark user's confirmedEmail as true: %v\n", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot count rows affected: %v\n", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return errorNoSuchConfirmationToken
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
DELETE FROM ownerConfirmHexes
|
||||||
|
WHERE confirmHex=$1;
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, confirmHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("cannot remove confirmation token: %v\n", err)
|
||||||
|
// Don't return an error because this is not critical.
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerConfirmHexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if confirmHex := r.FormValue("confirmHex"); confirmHex != "" {
|
||||||
|
if err := ownerConfirmHex(confirmHex); err == nil {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("%s/login?confirmed=true", os.Getenv("FRONTEND")), http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: include error message in the URL
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("%s/login?confirmed=false", os.Getenv("FRONTEND")), http.StatusTemporaryRedirect)
|
||||||
|
}
|
58
api/owner_confirm_hex_test.go
Normal file
58
api/owner_confirm_hex_test.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerConfirmHexBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
UPDATE owners
|
||||||
|
SET confirmedEmail=false;
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error when setting confirmedEmail=false: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmHex, _ := randomHex(32)
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
INSERT INTO
|
||||||
|
ownerConfirmHexes (confirmHex, ownerHex, sendDate)
|
||||||
|
VALUES ($1, $2, $3 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error creating inserting confirmHex: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = ownerConfirmHex(confirmHex); err != nil {
|
||||||
|
t.Errorf("unexpected error confirming hex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
SELECT confirmedEmail
|
||||||
|
FROM owners
|
||||||
|
WHERE ownerHex=$1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, ownerHex)
|
||||||
|
|
||||||
|
var confirmedHex bool
|
||||||
|
if err = row.Scan(&confirmedHex); err != nil {
|
||||||
|
t.Errorf("unexpected error scanning confirmedEmail: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !confirmedHex {
|
||||||
|
t.Errorf("confirmedHex expected to be true after confirmation; found to be false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
48
api/owner_get.go
Normal file
48
api/owner_get.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
func ownerGetByEmail(email string) (owner, error) {
|
||||||
|
if email == "" {
|
||||||
|
return owner{}, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT ownerHex, email, name, confirmedEmail, joinDate
|
||||||
|
FROM owners
|
||||||
|
WHERE email=$1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, email)
|
||||||
|
|
||||||
|
var o owner
|
||||||
|
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil {
|
||||||
|
// TODO: Make sure this is actually no such email.
|
||||||
|
return owner{}, errorNoSuchEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerGetBySession(session string) (owner, error) {
|
||||||
|
if session == "" {
|
||||||
|
return owner{}, errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT ownerHex, email, name, confirmedEmail, joinDate
|
||||||
|
FROM owners
|
||||||
|
WHERE email IN (
|
||||||
|
SELECT email FROM ownerSessions
|
||||||
|
WHERE session=$1
|
||||||
|
);
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, session)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
59
api/owner_get_test.go
Normal file
59
api/owner_get_test.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerGetByEmailBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
|
||||||
|
o, err := ownerGetByEmail("test@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error on ownerGetByEmail: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.OwnerHex != ownerHex {
|
||||||
|
t.Errorf("expected ownerHex=%s got ownerHex=%s", ownerHex, o.OwnerHex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerGetByEmailDNE(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := ownerGetByEmail("invalid@example.com"); err == nil {
|
||||||
|
t.Errorf("expected error not found on ownerGetByEmail before creating an account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerGetBySessionBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
|
||||||
|
session, _ := ownerLogin("test@example.com", "hunter2")
|
||||||
|
|
||||||
|
o, err := ownerGetBySession(session)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error on ownerGetBySession: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.OwnerHex != ownerHex {
|
||||||
|
t.Errorf("expected ownerHex=%s got ownerHex=%s", ownerHex, o.OwnerHex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerGetBySessionDNE(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := ownerGetBySession("does-not-exist"); err == nil {
|
||||||
|
t.Errorf("expected error not found on ownerGetBySession before creating an account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
76
api/owner_login.go
Normal file
76
api/owner_login.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownerLogin(email string, password string) (string, error) {
|
||||||
|
if email == "" || password == "" {
|
||||||
|
return "", errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
SELECT ownerHex, confirmedEmail, passwordHash
|
||||||
|
FROM owners
|
||||||
|
WHERE email=$1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, email)
|
||||||
|
|
||||||
|
var ownerHex string
|
||||||
|
var confirmedEmail bool
|
||||||
|
var passwordHash string
|
||||||
|
if err := row.Scan(&ownerHex, &confirmedEmail, &passwordHash); err != nil {
|
||||||
|
return "", errorInvalidEmailPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
if !confirmedEmail {
|
||||||
|
return "", errorUnconfirmedEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
|
||||||
|
// TODO: is this the only possible error?
|
||||||
|
return "", errorInvalidEmailPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot create session hex: %v", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
INSERT INTO
|
||||||
|
ownerSessions (session, ownerHex, loginDate)
|
||||||
|
VALUES ($1, $2, $3 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, session, ownerHex, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot insert session token: %v\n", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := ownerLogin(*x.Email, *x.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true, "session": session})
|
||||||
|
}
|
47
api/owner_login_test.go
Normal file
47
api/owner_login_test.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerLoginBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := ownerLogin("test@example.com", "hunter2"); err == nil {
|
||||||
|
t.Errorf("expected error not found when logging in without creating an account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
|
||||||
|
if _, err := ownerLogin("test@example.com", "hunter2"); err != nil {
|
||||||
|
t.Errorf("unexpected error when logging in: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ownerLogin("test@example.com", "h******"); err == nil {
|
||||||
|
t.Errorf("expected error not found when given wrong password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session, err := ownerLogin("test@example.com", "hunter2"); session == "" {
|
||||||
|
t.Errorf("empty session on successful login: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerLoginEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := ownerLogin("test@example.com", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when passing empty password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
|
||||||
|
if _, err := ownerLogin("test@example.com", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when passing empty password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
83
api/owner_new.go
Normal file
83
api/owner_new.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownerNew(email string, name string, password string) (string, error) {
|
||||||
|
if email == "" || name == "" || password == "" {
|
||||||
|
return "", errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerHex, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot generate ownerHex: %v", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot generate hash from password: %v\n", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
owners (ownerHex, email, name, passwordHash, joinDate, confirmedEmail)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, ownerHex, email, name, string(passwordHash), time.Now().UTC(), !smtpConfigured)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Make sure `err` is actually about conflicting UNIQUE, and not some
|
||||||
|
// other error. If it is something else, we should probably return `errorInternal`.
|
||||||
|
return "", errorEmailAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
if smtpConfigured {
|
||||||
|
confirmHex, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot generate confirmHex: %v", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
INSERT INTO
|
||||||
|
ownerConfirmHexes (confirmHex, ownerHex, sendDate)
|
||||||
|
VALUES ($1, $2, $3 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot insert confirmHex: %v\n", err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = smtpOwnerConfirmHex(email, name, confirmHex); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ownerHex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ownerNew(*x.Email, *x.Name, *x.Password); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
42
api/owner_new_test.go
Normal file
42
api/owner_new_test.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerNewBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := ownerNew("test@example.com", "Test", "hunter2"); err != nil {
|
||||||
|
t.Errorf("unexpected error when creating new owner: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerNewClash(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := ownerNew("test@example.com", "Test", "hunter2"); err != nil {
|
||||||
|
t.Errorf("unexpected error when creating new owner: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ownerNew("test@example.com", "Test", "hunter2"); err == nil {
|
||||||
|
t.Errorf("expected error not found when creating with clashing email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerNewEmpty(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if _, err := ownerNew("test@example.com", "", "hunter2"); err == nil {
|
||||||
|
t.Errorf("expected error not found when passing empty name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ownerNew("", "", ""); err == nil {
|
||||||
|
t.Errorf("expected error not found when passing empty everything")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
70
api/owner_reset_hex.go
Normal file
70
api/owner_reset_hex.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownerSendResetHex(email string) error {
|
||||||
|
if email == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := ownerGetByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
if err == errorNoSuchEmail {
|
||||||
|
// TODO: use a more random time instead.
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
logger.Errorf("cannot get owner by email: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !smtpConfigured {
|
||||||
|
return errorSmtpNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
resetHex, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
ownerResetHexes (resetHex, ownerHex, sendDate)
|
||||||
|
VALUES ($1, $2, $3 );
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, resetHex, o.OwnerHex, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot insert resetHex: %v", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
err = smtpOwnerResetHex(email, o.Name, resetHex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerSendResetHexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Email *string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ownerSendResetHex(*x.Email); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
72
api/owner_reset_password.go
Normal file
72
api/owner_reset_password.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownerResetPassword(resetHex string, password string) error {
|
||||||
|
if resetHex == "" || password == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot generate hash from password: %v\n", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
UPDATE owners SET passwordHash=$1
|
||||||
|
WHERE email IN (
|
||||||
|
SELECT email FROM ownerResetHexes
|
||||||
|
WHERE resetHex=$2
|
||||||
|
);
|
||||||
|
`
|
||||||
|
res, err := db.Exec(statement, string(passwordHash), resetHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot change user's password: %v\n", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot count rows affected: %v\n", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return errorNoSuchResetToken
|
||||||
|
}
|
||||||
|
|
||||||
|
statement = `
|
||||||
|
DELETE FROM ownerResetHexes
|
||||||
|
WHERE resetHex=$1;
|
||||||
|
`
|
||||||
|
_, err = db.Exec(statement, resetHex)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("cannot remove reset token: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerResetPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
ResetHex *string `json:"resetHex"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ownerResetPassword(*x.ResetHex, *x.Password); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true})
|
||||||
|
}
|
40
api/owner_reset_password_test.go
Normal file
40
api/owner_reset_password_test.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerResetPasswordBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
|
||||||
|
resetHex, _ := randomHex(32)
|
||||||
|
|
||||||
|
statement := `
|
||||||
|
INSERT INTO
|
||||||
|
ownerResetHexes (resetHex, ownerHex, sendDate)
|
||||||
|
VALUES ($1, $2, $3 );
|
||||||
|
`
|
||||||
|
_, err := db.Exec(statement, resetHex, ownerHex, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error inserting resetHex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = ownerResetPassword(resetHex, "hunter3"); err != nil {
|
||||||
|
t.Errorf("unexpected error resetting password: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ownerLogin("test@example.com", "hunter2"); err == nil {
|
||||||
|
t.Errorf("expected error not found when given old password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ownerLogin("test@example.com", "hunter3"); err != nil {
|
||||||
|
t.Errorf("unexpected error when logging in: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
30
api/owner_self.go
Normal file
30
api/owner_self.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownerSelf(session string) (bool, owner) {
|
||||||
|
o, err := ownerGetBySession(session)
|
||||||
|
if err != nil {
|
||||||
|
return false, owner{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, o
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerSelfHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type request struct {
|
||||||
|
Session *string `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var x request
|
||||||
|
if err := unmarshalBody(r, &x); err != nil {
|
||||||
|
writeBody(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loggedIn, o := ownerSelf(*x.Session)
|
||||||
|
|
||||||
|
writeBody(w, response{"success": true, "loggedIn": loggedIn, "owner": o})
|
||||||
|
}
|
32
api/owner_self_test.go
Normal file
32
api/owner_self_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerSelfBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
ownerNew("test@example.com", "Test", "hunter2")
|
||||||
|
session, _ := ownerLogin("test@example.com", "hunter2")
|
||||||
|
|
||||||
|
loggedIn, o := ownerSelf(session)
|
||||||
|
if !loggedIn {
|
||||||
|
t.Errorf("expected loggedIn=true got loggedIn=false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.Name != "Test" {
|
||||||
|
t.Errorf("expected name=Test got name=%s", o.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerSelfNotLoggedIn(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
if loggedIn, _ := ownerSelf("does-not-exist"); loggedIn {
|
||||||
|
t.Errorf("expected loggedIn=false got loggedIn=true")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
32
api/router.go
Normal file
32
api/router.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/handlers"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveRoutes() error {
|
||||||
|
router := mux.NewRouter()
|
||||||
|
|
||||||
|
if err := initAPIRouter(router); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initStaticRouter(router); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
origins := handlers.AllowedOrigins([]string{"*"})
|
||||||
|
headers := handlers.AllowedHeaders([]string{"X-Requested-With"})
|
||||||
|
methods := handlers.AllowedMethods([]string{"GET", "POST"})
|
||||||
|
|
||||||
|
logger.Infof("starting server on port %s\n", os.Getenv("PORT"))
|
||||||
|
if err := http.ListenAndServe(":"+os.Getenv("PORT"), handlers.CORS(origins, headers, methods)(router)); err != nil {
|
||||||
|
logger.Errorf("cannot start server: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
34
api/router_api.go
Normal file
34
api/router_api.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initAPIRouter(router *mux.Router) error {
|
||||||
|
router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET")
|
||||||
|
router.HandleFunc("/api/owner/login", ownerLoginHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/owner/send-reset-hex", ownerSendResetHexHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/owner/reset-password", ownerResetPasswordHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/owner/self", ownerSelfHandler).Methods("POST")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/commenter/session/new", commenterSessionNewHandler).Methods("GET")
|
||||||
|
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/oauth/google/redirect", googleRedirectHandler).Methods("GET")
|
||||||
|
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET")
|
||||||
|
|
||||||
|
router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/comment/vote", commentVoteHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/comment/approve", commentApproveHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/comment/delete", commentDeleteHandler).Methods("POST")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
69
api/router_static.go
Normal file
69
api/router_static.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"html/template"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func redirectLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/login", 301)
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticHtmlPlugs struct {
|
||||||
|
CdnPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func initStaticRouter(router *mux.Router) error {
|
||||||
|
for _, path := range []string{"js", "css", "images"} {
|
||||||
|
router.PathPrefix("/" + path + "/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f, err := os.Stat("." + r.URL.Path)
|
||||||
|
if err != nil || f.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeFile(w, r, "."+r.URL.Path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := []string{
|
||||||
|
"login",
|
||||||
|
"signup",
|
||||||
|
"dashboard",
|
||||||
|
"account",
|
||||||
|
}
|
||||||
|
|
||||||
|
html := make(map[string]string)
|
||||||
|
for _, page := range pages {
|
||||||
|
contents, err := ioutil.ReadFile(page + ".html")
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot read file %s.html: %v", page, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := template.New(page).Parse(string(contents))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot parse %s.html template: %v", page, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
t.Execute(&buf, &staticHtmlPlugs{CdnPrefix: os.Getenv("CDN_PREFIX")})
|
||||||
|
|
||||||
|
html[page] = buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, page := range pages {
|
||||||
|
router.HandleFunc("/"+page, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintf(w, html[page])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
router.HandleFunc("/", redirectLogin).Methods("GET")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
31
api/smtp_configure.go
Normal file
31
api/smtp_configure.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var smtpConfigured bool
|
||||||
|
var smtpAuth smtp.Auth
|
||||||
|
|
||||||
|
func smtpConfigure() error {
|
||||||
|
username := os.Getenv("SMTP_USERNAME")
|
||||||
|
password := os.Getenv("SMTP_PASSWORD")
|
||||||
|
host := os.Getenv("SMTP_HOST")
|
||||||
|
if username == "" || password == "" || host == "" {
|
||||||
|
logger.Warningf("smtp not configured, no emails will be sent")
|
||||||
|
smtpConfigured = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("SMTP_FROM_ADDRESS") == "" {
|
||||||
|
logger.Errorf("SMTP_FROM_ADDRESS not set")
|
||||||
|
smtpConfigured = false
|
||||||
|
return errorMissingSmtpAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("configuring smtp: %s", host)
|
||||||
|
smtpAuth = smtp.PlainAuth("", username, password, host)
|
||||||
|
smtpConfigured = true
|
||||||
|
return nil
|
||||||
|
}
|
63
api/smtp_configure_test.go
Normal file
63
api/smtp_configure_test.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cleanSmtpVars() {
|
||||||
|
for _, env := range []string{"SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_HOST", "SMTP_FROM_ADDRESS"} {
|
||||||
|
os.Setenv(env, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpConfigureBasics(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
os.Setenv("SMTP_USERNAME", "test@example.com")
|
||||||
|
os.Setenv("SMTP_PASSWORD", "hunter2")
|
||||||
|
os.Setenv("SMTP_HOST", "smtp.commento.io")
|
||||||
|
os.Setenv("SMTP_FROM_ADDRESS", "no-reply@commento.io")
|
||||||
|
|
||||||
|
if err := smtpConfigure(); err != nil {
|
||||||
|
t.Errorf("unexpected error when configuring SMTP: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanSmtpVars()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpConfigureEmptyHost(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
os.Setenv("SMTP_USERNAME", "test@example.com")
|
||||||
|
os.Setenv("SMTP_PASSWORD", "hunter2")
|
||||||
|
os.Setenv("SMTP_FROM_ADDRESS", "no-reply@commento.io")
|
||||||
|
|
||||||
|
if err := smtpConfigure(); err != nil {
|
||||||
|
t.Errorf("unexpected error when configuring SMTP: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if smtpConfigured {
|
||||||
|
t.Errorf("SMTP configured when it should not be due to empty SMTP_HOST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanSmtpVars()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpConfigureEmptyAddress(t *testing.T) {
|
||||||
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
|
os.Setenv("SMTP_USERNAME", "test@example.com")
|
||||||
|
os.Setenv("SMTP_PASSWORD", "hunter2")
|
||||||
|
os.Setenv("SMTP_HOST", "smtp.commento.io")
|
||||||
|
|
||||||
|
if err := smtpConfigure(); err == nil {
|
||||||
|
t.Errorf("expected error not found; SMTP should not be configured when SMTP_FROM_ADDRESS is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanSmtpVars()
|
||||||
|
}
|
28
api/smtp_owner_confirm_hex.go
Normal file
28
api/smtp_owner_confirm_hex.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ownerConfirmHexPlugs struct {
|
||||||
|
Origin string
|
||||||
|
ConfirmHex string
|
||||||
|
}
|
||||||
|
|
||||||
|
func smtpOwnerConfirmHex(to string, toName string, confirmHex string) error {
|
||||||
|
var header bytes.Buffer
|
||||||
|
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Please confirm your email address"})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
templates["confirm-hex"].Execute(&body, &ownerConfirmHexPlugs{Origin: os.Getenv("ORIGIN"), ConfirmHex: confirmHex})
|
||||||
|
|
||||||
|
err := smtp.SendMail(os.Getenv("SMTP_HOST"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot send confirmation email: %v", err)
|
||||||
|
return errorCannotSendEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
28
api/smtp_owner_reset_hex.go
Normal file
28
api/smtp_owner_reset_hex.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ownerResetHexPlugs struct {
|
||||||
|
Origin string
|
||||||
|
ResetHex string
|
||||||
|
}
|
||||||
|
|
||||||
|
func smtpOwnerResetHex(to string, toName string, resetHex string) error {
|
||||||
|
var header bytes.Buffer
|
||||||
|
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
templates["reset-hex"].Execute(&body, &ownerResetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex})
|
||||||
|
|
||||||
|
err := smtp.SendMail(os.Getenv("SMTP_HOST"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot send reset email: %v", err)
|
||||||
|
return errorCannotSendEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
49
api/smtp_templates.go
Normal file
49
api/smtp_templates.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var headerTemplate *template.Template
|
||||||
|
|
||||||
|
type headerPlugs struct {
|
||||||
|
FromAddress string
|
||||||
|
ToName string
|
||||||
|
ToAddress string
|
||||||
|
Subject string
|
||||||
|
}
|
||||||
|
|
||||||
|
var templates map[string]*template.Template
|
||||||
|
|
||||||
|
func loadTemplates() error {
|
||||||
|
var err error
|
||||||
|
headerTemplate, err = template.New("header").Parse(`MIME-Version: 1.0
|
||||||
|
Content-Type: text/html; charset=UTF-8
|
||||||
|
From: {{.FromAddress}}
|
||||||
|
To: {{.ToName}} <{{.ToAddress}}>
|
||||||
|
Subject: {{.Subject}}
|
||||||
|
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("cannot parse header template: %v", err)
|
||||||
|
return errorMalformedTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{"confirm-hex"}
|
||||||
|
|
||||||
|
templates = make(map[string]*template.Template)
|
||||||
|
|
||||||
|
logger.Infof("loading templates: %v", names)
|
||||||
|
for _, name := range names {
|
||||||
|
var err error
|
||||||
|
templates[name] = template.New(name)
|
||||||
|
templates[name], err = template.ParseFiles(fmt.Sprintf("email/%s.html", name))
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("cannot parse %s.html: %v\n", name, err)
|
||||||
|
return errorMalformedTemplate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
128
api/testing.go
Normal file
128
api/testing.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func failTestOnError(t *testing.T, err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed test: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPublicTables() ([]string, error) {
|
||||||
|
statement := `
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname='public';
|
||||||
|
`
|
||||||
|
rows, err := db.Query(statement)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot query public tables: %v", err)
|
||||||
|
return []string{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tables := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var table string
|
||||||
|
if err = rows.Scan(&table); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot scan table name: %v", err)
|
||||||
|
return []string{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tables = append(tables, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropTables() error {
|
||||||
|
tables, err := getPublicTables()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
if table != "migrations" {
|
||||||
|
_, err = db.Exec(fmt.Sprintf("DROP TABLE %s;", table))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot drop %s: %v", table, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestDatabase() error {
|
||||||
|
os.Setenv("POSTGRES", "postgres://postgres:postgres@0.0.0.0/commento_test?sslmode=disable")
|
||||||
|
|
||||||
|
if err := connectDB(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dropTables(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := performMigrationsFromDir("../db/"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTables() error {
|
||||||
|
tables, err := getPublicTables()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
_, err = db.Exec(fmt.Sprintf("DELETE FROM %s;", table))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot clear %s: %v", table, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupComplete bool
|
||||||
|
|
||||||
|
func setupTestEnv() error {
|
||||||
|
if !setupComplete {
|
||||||
|
setupComplete = true
|
||||||
|
|
||||||
|
if err := createLogger(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print messages to console only if verbose. Sounds like a good idea to
|
||||||
|
// keep the console clean on `go test`.
|
||||||
|
if !testing.Verbose() {
|
||||||
|
logging.SetLevel(logging.CRITICAL, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setupTestDatabase(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createMarkdownRenderer(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := clearTables(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
16
api/utils_crypto.go
Normal file
16
api/utils_crypto.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func randomHex(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
logger.Errorf("cannot create %d-byte long random hex: %v\n", n, err)
|
||||||
|
return "", errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
29
api/utils_crypto_test.go
Normal file
29
api/utils_crypto_test.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRandomHexBasics(t *testing.T) {
|
||||||
|
hex1, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error creating hex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hex1 == "" {
|
||||||
|
t.Errorf("randomly generated hex empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hex2, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error creating hex: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hex1 == hex2 {
|
||||||
|
t.Errorf("two randomly generated hexes found to be the same: '%s'", hex1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
45
api/utils_http.go
Normal file
45
api/utils_http.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type response map[string]interface{}
|
||||||
|
|
||||||
|
// TODO: Add tests in utils_http_test.go
|
||||||
|
|
||||||
|
func unmarshalBody(r *http.Request, x interface{}) error {
|
||||||
|
b, err := ioutil.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("cannot read POST body: %v\n", err)
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(b, x); err != nil {
|
||||||
|
return errorInvalidJSONBody
|
||||||
|
}
|
||||||
|
|
||||||
|
xv := reflect.Indirect(reflect.ValueOf(x))
|
||||||
|
for i := 0; i < xv.NumField(); i++ {
|
||||||
|
if xv.Field(i).IsNil() {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBody(w http.ResponseWriter, x map[string]interface{}) error {
|
||||||
|
resp, err := json.Marshal(x)
|
||||||
|
if err != nil {
|
||||||
|
w.Write([]byte(`{"success":false,"message":"Some internal error occurred"}`))
|
||||||
|
logger.Errorf("cannot marshal response: %v\n")
|
||||||
|
return errorInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(resp)
|
||||||
|
return nil
|
||||||
|
}
|
15
api/utils_logging.go
Normal file
15
api/utils_logging.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
var logger *logging.Logger
|
||||||
|
|
||||||
|
func createLogger() error {
|
||||||
|
format := logging.MustStringFormatter("[%{level}] %{shortfile} %{shortfunc}(): %{message}")
|
||||||
|
logging.SetFormatter(format)
|
||||||
|
logger = logging.MustGetLogger("commento")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
21
api/utils_logging_test.go
Normal file
21
api/utils_logging_test.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateLoggerBasics(t *testing.T) {
|
||||||
|
logger = nil
|
||||||
|
|
||||||
|
if err := createLogger(); err != nil {
|
||||||
|
t.Errorf("unexpected error creating logger: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger == nil {
|
||||||
|
t.Errorf("logger null after createLogger()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("test message please ignore")
|
||||||
|
}
|
16
api/utils_misc.go
Normal file
16
api/utils_misc.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func concat(a bytes.Buffer, b bytes.Buffer) []byte {
|
||||||
|
return append(a.Bytes(), b.Bytes()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitIfError(err error) {
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
35
api/utils_sanitise.go
Normal file
35
api/utils_sanitise.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var prePlusMatch = regexp.MustCompile(`([^@\+]*)\+?(.*)@.*`)
|
||||||
|
var periodsMatch = regexp.MustCompile(`[\.]`)
|
||||||
|
var postAtMatch = regexp.MustCompile(`[^@]*(@.*)`)
|
||||||
|
|
||||||
|
func stripEmail(email string) string {
|
||||||
|
postAt := postAtMatch.ReplaceAllString(email, `$1`)
|
||||||
|
prePlus := prePlusMatch.ReplaceAllString(email, `$1`)
|
||||||
|
strippedEmail := periodsMatch.ReplaceAllString(prePlus, ``) + postAt
|
||||||
|
|
||||||
|
return strippedEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
var https = regexp.MustCompile(`(https?://)`)
|
||||||
|
var trailingSlash = regexp.MustCompile(`(/*$)`)
|
||||||
|
|
||||||
|
func stripDomain(domain string) string {
|
||||||
|
noSlash := trailingSlash.ReplaceAllString(domain, ``)
|
||||||
|
noProtocol := https.ReplaceAllString(noSlash, ``)
|
||||||
|
|
||||||
|
return noProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = regexp.MustCompile(`(https?://[^/]*)`)
|
||||||
|
|
||||||
|
func stripPath(url string) string {
|
||||||
|
strippedPath := path.ReplaceAllString(url, ``)
|
||||||
|
|
||||||
|
return strippedPath
|
||||||
|
}
|
20
api/utils_sanitise_test.go
Normal file
20
api/utils_sanitise_test.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStripEmailBasics(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"test@example.com": "test@example.com",
|
||||||
|
"test+strip@example.com": "test@example.com",
|
||||||
|
"test+strip+strip2@example.com": "test@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
for in, out := range tests {
|
||||||
|
if stripEmail(in) != out {
|
||||||
|
t.Errorf("for in=%s expected out=%s got out=%s", in, out, stripEmail(in))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user