diff --git a/api/comment_list.go b/api/comment_list.go index 173dfd3..52bab41 100644 --- a/api/comment_list.go +++ b/api/comment_list.go @@ -111,6 +111,12 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) { return } + p, err := pageGet(domain, path) + if err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + commenterHex := "anonymous" isModerator := false if *x.CommenterToken != "anonymous" { @@ -151,6 +157,7 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) { "requireIdentification": d.RequireIdentification, "isFrozen": d.State == "frozen", "isModerator": isModerator, + "attributes": p, "configuredOauths": configuredOauths, }) } diff --git a/api/comment_new.go b/api/comment_new.go index 15f8e7b..b35eb2b 100644 --- a/api/comment_new.go +++ b/api/comment_new.go @@ -13,6 +13,16 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin return "", errorMissingField } + p, err := pageGet(domain, path) + if err != nil { + logger.Errorf("cannot get page attributes: %v", err) + return "", errorInternal + } + + if p.IsLocked { + return "", errorThreadLocked + } + commentHex, err := randomHex(32) if err != nil { return "", err @@ -31,6 +41,10 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin return "", errorInternal } + if err = pageNew(domain, path); err != nil { + return "", err + } + return commentHex, nil } diff --git a/api/comment_new_test.go b/api/comment_new_test.go index daad2ca..16b1293 100644 --- a/api/comment_new_test.go +++ b/api/comment_new_test.go @@ -56,3 +56,18 @@ func TestCommentNewUpvoted(t *testing.T) { return } } + +func TestCommentNewThreadLocked(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + pageNew("example.com", "/path.html") + p, _ := pageGet("example.com", "/path.html") + p.IsLocked = true + pageUpdate(p) + + _, err := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC()) + if err == nil { + t.Errorf("expected error not found creating a new comment on a locked thread") + return + } +} diff --git a/api/errors.go b/api/errors.go index 1b250e2..c6058a6 100644 --- a/api/errors.go +++ b/api/errors.go @@ -41,3 +41,4 @@ var errorSelfVote = errors.New("You cannot vote on your own comment.") var errorInvalidConfigFile = errors.New("Invalid config file.") var errorInvalidConfigValue = errors.New("Invalid config value.") var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.") +var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.") diff --git a/api/page.go b/api/page.go new file mode 100644 index 0000000..01bc661 --- /dev/null +++ b/api/page.go @@ -0,0 +1,9 @@ +package main + +import () + +type page struct { + Domain string `json:"domain"` + Path string `json:"path"` + IsLocked bool `json:"isLocked"` +} diff --git a/api/page_get.go b/api/page_get.go new file mode 100644 index 0000000..d76eda2 --- /dev/null +++ b/api/page_get.go @@ -0,0 +1,34 @@ +package main + +import ( + "database/sql" +) + +func pageGet(domain string, path string) (page, error) { + // path can be empty + if domain == "" { + return page{}, errorMissingField + } + + statement := ` + SELECT isLocked + FROM pages + WHERE domain=$1 AND path=$2; + ` + row := db.QueryRow(statement, domain, path) + + p := page{Domain: domain, Path: path} + if err := row.Scan(&p.IsLocked); err != nil { + if err == sql.ErrNoRows { + // If there haven't been any comments, there won't be a record for this + // page. The sane thing to do is return defaults. + // TODO: the defaults are hard-coded in two places: here and the schema + p.IsLocked = false + } else { + logger.Errorf("error scanning page: %v", err) + return page{}, errorInternal + } + } + + return p, nil +} diff --git a/api/page_get_test.go b/api/page_get_test.go new file mode 100644 index 0000000..7b58d20 --- /dev/null +++ b/api/page_get_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +func TestPageGetBasics(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + pageNew("example.com", "/path.html") + + p, err := pageGet("example.com", "/path.html") + if err != nil { + t.Errorf("unexpected error getting page: %v", err) + return + } + + if p.IsLocked != false { + t.Errorf("expected p.IsLocked=false got %v", p.IsLocked) + return + } + + if _, err := pageGet("example.com", "/path2.html"); err != nil { + t.Errorf("unexpected error getting page with non-existant record: %v", err) + return + } +} + +func TestPageGetEmpty(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + pageNew("example.com", "") + + if _, err := pageGet("example.com", ""); err != nil { + t.Errorf("unexpected error getting page with empty path: %v", err) + return + } + + if _, err := pageGet("", "/path.html"); err == nil { + t.Errorf("exepected error not found when getting page with empty domain") + return + } +} diff --git a/api/page_new.go b/api/page_new.go new file mode 100644 index 0000000..1db55cf --- /dev/null +++ b/api/page_new.go @@ -0,0 +1,24 @@ +package main + +import () + +func pageNew(domain string, path string) error { + // path can be empty + if domain == "" { + return errorMissingField + } + + statement := ` + INSERT INTO + pages (domain, path) + VALUES ($1, $2 ) + ON CONFLICT DO NOTHING; + ` + _, err := db.Exec(statement, domain, path) + if err != nil { + logger.Errorf("error inserting new page: %v", err) + return errorInternal + } + + return nil +} diff --git a/api/page_new_test.go b/api/page_new_test.go new file mode 100644 index 0000000..7b28166 --- /dev/null +++ b/api/page_new_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +func TestPageNewBasics(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + if err := pageNew("example.com", "/path.html"); err != nil { + t.Errorf("unexpected error creating page: %v", err) + return + } +} + +func TestPageNewEmpty(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + if err := pageNew("example.com", ""); err != nil { + t.Errorf("unexpected error creating page with empty path: %v", err) + return + } + + if err := pageNew("", "/path.html"); err == nil { + t.Errorf("expected error not found creating page with empty domain") + return + } +} + +func TestPageNewUnique(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + if err := pageNew("example.com", "/path.html"); err != nil { + t.Errorf("unexpected error creating page: %v", err) + return + } + + // no error should be returned when trying to duplicate insert + if err := pageNew("example.com", "/path.html"); err != nil { + t.Errorf("unexpected error creating same page twice: %v", err) + return + } +} diff --git a/api/page_update.go b/api/page_update.go new file mode 100644 index 0000000..2a50165 --- /dev/null +++ b/api/page_update.go @@ -0,0 +1,70 @@ +package main + +import ( + "net/http" +) + +func pageUpdate(p page) error { + if p.Domain == "" { + return errorMissingField + } + + statement := ` + INSERT INTO + pages (domain, path, isLocked) + VALUES ($1, $2, $3 ) + ON CONFLICT (domain, path) DO + UPDATE SET isLocked = $3; + ` + _, err := db.Exec(statement, p.Domain, p.Path, p.IsLocked) + if err != nil { + logger.Errorf("error setting page attributes: %v", err) + return errorInternal + } + + return nil +} + +func pageUpdateHandler(w http.ResponseWriter, r *http.Request) { + type request struct { + CommenterToken *string `json:"commenterToken"` + Domain *string `json:"domain"` + Path *string `json:"path"` + Attributes *page `json:"attributes"` + } + + var x request + if err := bodyUnmarshal(r, &x); err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + c, err := commenterGetByCommenterToken(*x.CommenterToken) + if err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + domain := domainStrip(*x.Domain) + + isModerator, err := isDomainModerator(domain, c.Email) + if err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + if !isModerator { + bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()}) + return + } + + (*x.Attributes).Domain = *x.Domain + (*x.Attributes).Path = *x.Path + + if err = pageUpdate(*x.Attributes); err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + bodyMarshal(w, response{"success": true}) +} diff --git a/api/page_update_test.go b/api/page_update_test.go new file mode 100644 index 0000000..32b36dc --- /dev/null +++ b/api/page_update_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + "time" +) + +func TestPageUpdateBasics(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google", "") + + commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC()) + + p, _ := pageGet("example.com", "/path.html") + if p.IsLocked != false { + t.Errorf("expected IsLocked=false got %v", p.IsLocked) + return + } + + p.IsLocked = true + + if err := pageUpdate(p); err != nil { + t.Errorf("unexpected error updating page: %v", err) + return + } + + p, _ = pageGet("example.com", "/path.html") + if p.IsLocked != true { + t.Errorf("expected IsLocked=true got %v", p.IsLocked) + return + } +} + +func TestPageUpdateEmpty(t *testing.T) { + failTestOnError(t, setupTestEnv()) + + p := page{Domain: "", Path: "", IsLocked: false} + if err := pageUpdate(p); err == nil { + t.Errorf("expected error not found updating page with empty everything") + return + } +} diff --git a/api/router_api.go b/api/router_api.go index c9684c0..5199dc1 100644 --- a/api/router_api.go +++ b/api/router_api.go @@ -35,5 +35,7 @@ func apiRouterInit(router *mux.Router) error { router.HandleFunc("/api/comment/approve", commentApproveHandler).Methods("POST") router.HandleFunc("/api/comment/delete", commentDeleteHandler).Methods("POST") + router.HandleFunc("/api/page/update", pageUpdateHandler).Methods("POST") + return nil } diff --git a/db/20180922181651-page-attributes.sql b/db/20180922181651-page-attributes.sql new file mode 100644 index 0000000..f2776d5 --- /dev/null +++ b/db/20180922181651-page-attributes.sql @@ -0,0 +1,9 @@ +-- Introduces page attributes + +CREATE TABLE IF NOT EXISTS pages ( + domain TEXT NOT NULL , + path TEXT NOT NULL , + isLocked BOOLEAN NOT NULL DEFAULT false +); + +CREATE UNIQUE INDEX pagesUniqueIndex ON pages(domain, path); diff --git a/frontend/js/commento.js b/frontend/js/commento.js index f868d32..e6f464c 100644 --- a/frontend/js/commento.js +++ b/frontend/js/commento.js @@ -27,6 +27,8 @@ var ID_LOGIN_BOX_HR = "commento-login-box-hr"; var ID_LOGIN_BOX_OAUTH_PRETEXT = "commento-login-box-oauth-pretext"; var ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER = "commento-login-box-oauth-buttons-container"; + var ID_MOD_TOOLS = "commento-mod-tools"; + var ID_MOD_TOOLS_LOCK_BUTTON = "commento-mod-tools-lock-button"; var ID_ERROR = "commento-error"; var ID_LOGGED_CONTAINER = "commento-logged-container"; var ID_COMMENTS_AREA = "commento-comments-area"; @@ -62,8 +64,9 @@ var requireModeration = true; var isModerator = false; var isFrozen = false; - var chosenAnonymous = false; var shownSubmitButton = {"root": false}; + var chosenAnonymous = false; + var isLocked = false; var shownReply = {}; var configuredOauths = []; var loginBoxType = "signup"; @@ -336,6 +339,9 @@ requireIdentification = resp.requireIdentification; isModerator = resp.isModerator; isFrozen = resp.isFrozen; + + isLocked = resp.attributes.isLocked; + comments = resp.comments; commenters = resp.commenters; configuredOauths = resp.configuredOauths; @@ -443,7 +449,11 @@ commentsArea.innerHTML = ""; - append(mainArea, textareaCreate("root")); + if (!isLocked) + append(mainArea, textareaCreate("root")); + else + append(mainArea, messageCreate("This thread is locked. You cannot create new comments.")); + append(mainArea, commentsArea); append(root, mainArea); @@ -715,7 +725,10 @@ // append(options, edit); // uncomment when implemented append(options, downvote); append(options, upvote); - append(options, reply); + + if (!isLocked) + append(options, reply); + if (isModerator) { append(options, remove); if (comment.state == "unapproved") @@ -1257,6 +1270,45 @@ } + function pageUpdate(callback) { + var attributes = { + "isLocked": isLocked, + }; + + var json = { + "commenterToken": commenterTokenGet(), + "domain": location.host, + "path": location.pathname, + "attributes": attributes, + }; + + post(origin + "/api/page/update", json, function(resp) { + if (!resp.success) { + errorShow(resp.message); + return + } + + call(callback); + }); + } + + + global.threadLockToggle = function() { + var lock = $(ID_MOD_TOOLS_LOCK_BUTTON); + + isLocked = !isLocked; + + lock.disabled = true; + pageUpdate(function(success) { + lock.disabled = false; + if (isLocked) + lock.innerHTML = "Unlock Thread"; + else + lock.innerHTML = "Lock Thread"; + }); + } + + function mainAreaCreate() { var mainArea = create("div"); @@ -1270,6 +1322,29 @@ } + function modToolsCreate() { + var modTools = create("div"); + var lock = create("button"); + + modTools.id = ID_MOD_TOOLS; + lock.id = ID_MOD_TOOLS_LOCK_BUTTON; + + classAdd(modTools, "mod-tools"); + classAdd(lock, "mod-tools-lock-button"); + + if (isLocked) + lock.innerHTML = "Unlock Thread"; + else + lock.innerHTML = "Lock Thread"; + + attrSet(modTools, "style", "display: none"); + attrSet(lock, "onclick", "threadLockToggle()"); + + append(modTools, lock); + append(root, modTools); + } + + global.loadCssOverride = function() { if (cssOverride === undefined) global.allShow(); @@ -1280,12 +1355,18 @@ global.allShow = function() { var mainArea = $(ID_MAIN_AREA); + var modTools = $(ID_MOD_TOOLS); var loggedContainer = $(ID_LOGGED_CONTAINER); var footer = $(ID_FOOTER); attrSet(mainArea, "style", ""); + + if (isModerator) + attrSet(modTools, "style", ""); + if (loggedContainer) attrSet(loggedContainer, "style", ""); + attrSet(footer, "style", ""); nameWidthFix(); @@ -1346,6 +1427,7 @@ selfGet(function() { commentsGet(function() { + modToolsCreate(); rootCreate(function() { commentsRender(); footerLoad(); diff --git a/frontend/sass/commento-input.scss b/frontend/sass/commento-input.scss index c3b007e..73408a4 100644 --- a/frontend/sass/commento-input.scss +++ b/frontend/sass/commento-input.scss @@ -177,3 +177,18 @@ textarea { .commento-button-margin { padding-bottom: 60px; } + +.commento-mod-tools-lock-button { + color: $gray-6; + background: none; + border: none; + font-weight: bold; + font-size: 12px; + text-transform: uppercase; + margin-left: 12px; + padding: 2px; +} + +.commento-mod-tools-lock-button:hover { + cursor: pointer; +} diff --git a/frontend/sass/commento.scss b/frontend/sass/commento.scss index a5a1129..3ed13c2 100644 --- a/frontend/sass/commento.scss +++ b/frontend/sass/commento.scss @@ -41,6 +41,18 @@ font-weight: bold; } + .commento-mod-tools { + margin-bottom: 16px; + } + + .commento-mod-tools::before { + content: "Moderators"; + text-transform: uppercase; + color: $indigo-8; + font-size: 12px; + font-weight: bold; + } + .commento-moderation-notice { width: 100%; border-radius: 4px; @@ -87,7 +99,7 @@ border-top: 1px solid #f0f0f0; .commento-header { - padding-bottom: 12px; + padding-bottom: 4px; } .commento-avatar::after {