├── db ├── 20190218183556-v1.6.1.sql ├── 20180724125115-remove-config.sql ├── 20181007231407-v1.1.4.sql ├── 20181228114101-v1.4.0.sql ├── 20181228114101-v1.4.1.sql ├── 20190123002724-v1.4.2.sql ├── 20190204180609-v1.5.0.sql ├── 20190218173502-v1.6.0.sql ├── 20190219001130-v1.6.2.sql ├── 20190501201032-v1.7.0.sql ├── 20190508222848-reset-count.sql ├── 20180610215858-commenter-password.sql ├── 20181218183803-sticky-comments.sql ├── 20180620083655-session-token-renamme.sql ├── 20181007230906-store-version.sql ├── 20190122235525-anonymous-moderation-default.sql ├── 20190913175445-delete-comments.sql ├── 20190131002240-export.sql ├── 20190606000842-reset-hex.sql ├── 20190420181913-sso.sql ├── 20191204173000-sort-method.sql ├── 20180923004309-comment-count-build.sql ├── 20190420231030-sso-tokens.sql ├── 20180922181651-page-attributes.sql ├── 20190505191006-comment-count-decrease.sql ├── 20180923002745-comment-count.sql ├── 20190418210855-configurable-auth.sql ├── new.sh ├── 20200730134007-comment-count-update.sql ├── Makefile └── 20190213033530-email-notifications.sql ├── frontend ├── .gitignore ├── images │ ├── banner.png │ └── 120x120.png ├── sass │ ├── auth.scss │ ├── dashboard.scss │ ├── unsubscribe.scss │ ├── commento-mod-tools.scss │ ├── button.scss │ ├── commento-footer.scss │ ├── commento-logged.scss │ ├── unsubscribe-main.scss │ ├── navbar-main.scss │ ├── commento-oauth.scss │ ├── email-main.scss │ ├── tomorrow.scss │ ├── common-main.scss │ └── commento-login.scss ├── fonts │ ├── source-sans-300-greek.woff2 │ ├── source-sans-300-latin.woff2 │ ├── source-sans-400-greek.woff2 │ ├── source-sans-400-latin.woff2 │ ├── source-sans-700-greek.woff2 │ ├── source-sans-700-latin.woff2 │ ├── source-sans-300-cyrillic.woff2 │ ├── source-sans-300-greek-ext.woff2 │ ├── source-sans-300-latin-ext.woff2 │ ├── source-sans-400-cyrillic.woff2 │ ├── source-sans-400-greek-ext.woff2 │ ├── source-sans-400-latin-ext.woff2 │ ├── source-sans-700-cyrillic.woff2 │ ├── source-sans-700-greek-ext.woff2 │ ├── source-sans-700-latin-ext.woff2 │ ├── source-sans-300-vietnamese.woff2 │ ├── source-sans-400-vietnamese.woff2 │ ├── source-sans-700-vietnamese.woff2 │ ├── source-sans-300-cyrillic-ext.woff2 │ ├── source-sans-400-cyrillic-ext.woff2 │ └── source-sans-700-cyrillic-ext.woff2 ├── Makefile ├── js │ ├── logout.js │ ├── constants.js │ ├── dashboard-installation.js │ ├── auth-common.js │ ├── self.js │ ├── dashboard-export.js │ ├── errors.js │ ├── http.js │ ├── dashboard-setting.js │ ├── forgot.js │ ├── reset.js │ ├── dashboard-general.js │ ├── signup.js │ ├── dashboard-danger.js │ ├── unsubscribe.js │ ├── settings.js │ └── dashboard-import.js ├── logout.html ├── .eslintrc ├── package.json ├── footer.html ├── confirm-email.html ├── forgot.html ├── reset.html └── login.html ├── api ├── constants.go ├── database.go ├── markdown_html.go ├── email_notification.go ├── owner.go ├── commenter_session_new_test.go ├── utils_misc.go ├── utils_sql.go ├── utils_gzip.go ├── page.go ├── utils_logging.go ├── utils_crypto.go ├── utils_logging_test.go ├── email.go ├── domain_view_record.go ├── commenter_session.go ├── cron_views_cleanup.go ├── commenter.go ├── cron_domain_export_cleanup.go ├── page_new.go ├── cron_sso_token.go ├── utils_sanitise_test.go ├── oauth.go ├── commenter_session_update.go ├── comment_domain_path_get.go ├── main.go ├── sigint.go ├── utils_crypto_test.go ├── domain_update_test.go ├── domain_ownership_verify.go ├── email_new.go ├── comment.go ├── comment_ownership_verify.go ├── oauth_github_redirect.go ├── oauth_gitlab_redirect.go ├── oauth_google_redirect.go ├── owner_self.go ├── utils_html.go ├── commenter_self.go ├── akismet.go ├── domain_moderator_new_test.go ├── page_title.go ├── domain_list_test.go ├── markdown.go ├── domain_export_download.go ├── smtp_reset_hex.go ├── smtp_domain_export_error.go ├── database_migrate_email_notifications.go ├── smtp_owner_confirm_hex.go ├── smtp_domain_export.go ├── domain_get_test.go ├── domain_delete_test.go ├── smtp_configure.go ├── comment_domain_path_get_test.go ├── go.mod ├── comment_delete_test.go ├── commenter_session_new.go ├── config_file.go ├── domain_ownership_verify_test.go ├── router.go ├── comment_approve_test.go ├── email_update.go ├── page_get.go ├── comment_statistics.go ├── comment_get.go ├── page_get_test.go ├── oauth_github.go ├── hub.go ├── page_new_test.go ├── owner_new_test.go ├── domain_new_test.go ├── oauth_twitter_redirect.go ├── page_update_test.go ├── oauth_google.go ├── commenter_new_test.go ├── commenter_session_update_test.go ├── comment_ownership_verify_test.go ├── markdown_html_test.go ├── Makefile ├── oauth_gitlab.go ├── utils_http.go ├── domain_moderator.go ├── domain_moderator_delete_test.go ├── owner_login_test.go ├── smtp_templates.go ├── domain.go ├── owner_confirm_hex_test.go ├── oauth_twitter.go ├── commenter_photo.go ├── oauth_google_test.go ├── database_connect.go ├── oauth_sso.go ├── owner_get_test.go ├── owner_confirm_hex.go ├── domain_moderator_delete.go ├── comment_count.go ├── utils_sanitise.go ├── domain_moderator_test.go ├── domain_new.go ├── comment_count_test.go ├── email_moderate.go ├── smtp_configure_test.go ├── domain_get.go ├── domain_sso.go ├── commenter_login_test.go ├── domain_moderator_new.go ├── domain_list.go ├── owner_delete.go ├── email_get.go ├── page_update.go ├── owner_get.go ├── version.go ├── domain_clear.go ├── comment_vote_test.go ├── comment_edit.go ├── owner_login.go ├── reset.go └── commenter_update.go ├── heroku.yml ├── run.sh ├── .editorconfig ├── templates ├── domain-export-error.txt ├── reset-hex.txt ├── confirm-hex.txt ├── domain-export.txt └── Makefile ├── etc ├── linux-systemd │ └── commento.service └── bsd-rc │ └── commento ├── scripts ├── gitlab-ci-build-prescript └── autoserve ├── .circleci └── config.yml ├── docker-compose.yml ├── update_repo.sh ├── LICENSE ├── .gitignore ├── Dockerfile └── Dockerfile.heroku /db/20190218183556-v1.6.1.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .sass-cache 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /api/constants.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var version string 4 | -------------------------------------------------------------------------------- /db/20180724125115-remove-config.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS config; 2 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile.heroku 4 | -------------------------------------------------------------------------------- /db/20181007231407-v1.1.4.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.2.0'; 3 | -------------------------------------------------------------------------------- /db/20181228114101-v1.4.0.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.4.0'; 3 | -------------------------------------------------------------------------------- /db/20181228114101-v1.4.1.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.4.1'; 3 | -------------------------------------------------------------------------------- /db/20190123002724-v1.4.2.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.4.2'; 3 | -------------------------------------------------------------------------------- /db/20190204180609-v1.5.0.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.5.0'; 3 | -------------------------------------------------------------------------------- /db/20190218173502-v1.6.0.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.6.0'; 3 | -------------------------------------------------------------------------------- /db/20190219001130-v1.6.2.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.6.0'; 3 | -------------------------------------------------------------------------------- /db/20190501201032-v1.7.0.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.7.0'; 3 | -------------------------------------------------------------------------------- /db/20190508222848-reset-count.sql: -------------------------------------------------------------------------------- 1 | UPDATE pages 2 | SET commentCount = commentCount + 1; 3 | -------------------------------------------------------------------------------- /api/database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | var db *sql.DB 8 | -------------------------------------------------------------------------------- /frontend/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/images/banner.png -------------------------------------------------------------------------------- /db/20180610215858-commenter-password.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE commenters 2 | ADD passwordHash TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /db/20181218183803-sticky-comments.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE pages 2 | ADD stickyCommentHex TEXT NOT NULL DEFAULT 'none'; 3 | -------------------------------------------------------------------------------- /frontend/images/120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/images/120x120.png -------------------------------------------------------------------------------- /frontend/sass/auth.scss: -------------------------------------------------------------------------------- 1 | @import "common-main.scss"; 2 | @import "navbar-main.scss"; 3 | @import "auth-main.scss"; 4 | -------------------------------------------------------------------------------- /frontend/fonts/source-sans-300-greek.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-300-greek.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-300-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-300-latin.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-400-greek.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-400-greek.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-400-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-400-latin.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-700-greek.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-700-greek.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-700-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-700-latin.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-300-cyrillic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-300-cyrillic.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-300-greek-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-300-greek-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-300-latin-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-300-latin-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-400-cyrillic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-400-cyrillic.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-400-greek-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-400-greek-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-400-latin-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-400-latin-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-700-cyrillic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-700-cyrillic.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-700-greek-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-700-greek-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-700-latin-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-700-latin-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-300-vietnamese.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-300-vietnamese.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-400-vietnamese.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-400-vietnamese.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-700-vietnamese.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-700-vietnamese.woff2 -------------------------------------------------------------------------------- /frontend/sass/dashboard.scss: -------------------------------------------------------------------------------- 1 | @import "common-main.scss"; 2 | @import "navbar-main.scss"; 3 | @import "dashboard-main.scss"; 4 | @import "tomorrow.scss"; 5 | -------------------------------------------------------------------------------- /frontend/fonts/source-sans-300-cyrillic-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-300-cyrillic-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-400-cyrillic-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-400-cyrillic-ext.woff2 -------------------------------------------------------------------------------- /frontend/fonts/source-sans-700-cyrillic-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/master/frontend/fonts/source-sans-700-cyrillic-ext.woff2 -------------------------------------------------------------------------------- /frontend/sass/unsubscribe.scss: -------------------------------------------------------------------------------- 1 | @import "common-main.scss"; 2 | @import "navbar-main.scss"; 3 | @import "unsubscribe-main.scss"; 4 | @import "tomorrow.scss"; 5 | -------------------------------------------------------------------------------- /db/20180620083655-session-token-renamme.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ownerSessions 2 | RENAME COLUMN session TO ownerToken; 3 | 4 | ALTER TABLE commenterSessions 5 | RENAME COLUMN session TO commenterToken 6 | -------------------------------------------------------------------------------- /db/20181007230906-store-version.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS config ( 2 | version TEXT NOT NULL 3 | ); 4 | 5 | INSERT INTO 6 | config (version) 7 | VALUES ('v1.1.3'); 8 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | # these vars are not available at buildtime so we need to import them at runtime 4 | export COMMENTO_PORT=$PORT 5 | export COMMENTO_POSTGRES=$DATABASE_URL 6 | 7 | ./commento 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [.*] 2 | charset: utf-8 3 | end_of_line: lf 4 | 5 | [*.go] 6 | indent_style = tab 7 | 8 | [*.js] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [Makefile] 13 | indent_style = tab -------------------------------------------------------------------------------- /templates/domain-export-error.txt: -------------------------------------------------------------------------------- 1 | You recently requested a data export of your Commento domain {{.Domain}}. An 2 | error was encountered while processing the request. Please contact support to 3 | resolve this issue. 4 | -------------------------------------------------------------------------------- /db/20190122235525-anonymous-moderation-default.sql: -------------------------------------------------------------------------------- 1 | -- Allow the owner to change whether anonymous comments are put into moderation by default. 2 | 3 | ALTER TABLE domains 4 | ADD COLUMN moderateAllAnonymous BOOLEAN DEFAULT true; 5 | -------------------------------------------------------------------------------- /db/20190913175445-delete-comments.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS commentsDeleteTrigger ON comments; 2 | 3 | DROP FUNCTION IF EXISTS commentsDeleteTriggerFunction(); 4 | 5 | ALTER TABLE comments 6 | ADD deleted BOOLEAN NOT NULL DEFAULT false; 7 | -------------------------------------------------------------------------------- /db/20190131002240-export.sql: -------------------------------------------------------------------------------- 1 | -- add export feature 2 | 3 | CREATE TABLE IF NOT EXISTS exports ( 4 | exportHex TEXT NOT NULL UNIQUE PRIMARY KEY, 5 | binData BYTEA NOT NULL, 6 | domain TEXT NOT NULL, 7 | creationDate TIMESTAMP NOT NULL 8 | ); 9 | -------------------------------------------------------------------------------- /db/20190606000842-reset-hex.sql: -------------------------------------------------------------------------------- 1 | -- Create the resetHexes table 2 | 3 | ALTER TABLE ownerResetHexes RENAME TO resetHexes; 4 | 5 | ALTER TABLE resetHexes RENAME ownerHex TO hex; 6 | 7 | ALTER TABLE resetHexes 8 | ADD entity TEXT NOT NULL DEFAULT 'owner'; 9 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | BUILD_DIR = build 2 | GULP = node_modules/.bin/gulp 3 | 4 | devel: 5 | yarn install 6 | $(GULP) devel 7 | 8 | prod: 9 | yarn install 10 | $(GULP) prod 11 | 12 | clean: 13 | -rm -rf $(BUILD_DIR); 14 | -------------------------------------------------------------------------------- /frontend/js/logout.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | global.logout = function() { 5 | global.cookieDelete("commentoOwnerToken"); 6 | document.location = global.origin + "/login"; 7 | } 8 | 9 | } (window.commento, document)); 10 | -------------------------------------------------------------------------------- /frontend/logout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /api/markdown_html.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/russross/blackfriday" 5 | ) 6 | 7 | func markdownToHtml(markdown string) string { 8 | unsafe := blackfriday.Markdown([]byte(markdown), renderer, extensions) 9 | return string(policy.SanitizeBytes(unsafe)) 10 | } 11 | -------------------------------------------------------------------------------- /db/20190420181913-sso.sql: -------------------------------------------------------------------------------- 1 | -- Single Sign-On (SSO) 2 | 3 | ALTER TABLE domains 4 | ADD ssoProvider BOOLEAN NOT NULL DEFAULT false; 5 | 6 | ALTER TABLE domains 7 | ADD ssoSecret TEXT NOT NULL DEFAULT ''; 8 | 9 | ALTER TABLE domains 10 | ADD ssoUrl TEXT NOT NULL DEFAULT ''; 11 | -------------------------------------------------------------------------------- /templates/reset-hex.txt: -------------------------------------------------------------------------------- 1 | Hi, 2 | 3 | Someone (probably you) recently initiated the procedure to reset your Commento account password. To do this, use the link below: 4 | 5 | {{.Origin}}/reset?hex={{.ResetHex}} 6 | 7 | If you did not initiate this request, you can safely ignore this email. 8 | -------------------------------------------------------------------------------- /api/email_notification.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | type emailNotification struct { 6 | Email string 7 | CommenterName string 8 | Domain string 9 | Path string 10 | Title string 11 | CommentHex string 12 | Kind string 13 | } 14 | -------------------------------------------------------------------------------- /db/20191204173000-sort-method.sql: -------------------------------------------------------------------------------- 1 | -- Default sort policy for each domain 2 | 3 | CREATE TYPE sortPolicy AS ENUM ( 4 | 'score-desc', 5 | 'creationdate-desc', 6 | 'creationdate-asc' 7 | ); 8 | 9 | ALTER TABLE domains 10 | ADD defaultSortPolicy sortPolicy NOT NULL DEFAULT 'score-desc'; 11 | -------------------------------------------------------------------------------- /templates/confirm-hex.txt: -------------------------------------------------------------------------------- 1 | Hi, 2 | 3 | You recently registered a new Commento account with this email address. If you wish to complete registration, use the link below: 4 | 5 | {{.Origin}}/api/owner/confirm-hex?confirmHex={{.ConfirmHex}} 6 | 7 | If you did not do initiate this, you can ignore this email. 8 | -------------------------------------------------------------------------------- /db/20180923004309-comment-count-build.sql: -------------------------------------------------------------------------------- 1 | -- Build the comments count column 2 | 3 | UPDATE pages 4 | SET commentCount = subquery.commentCount 5 | FROM ( 6 | SELECT COUNT(commentHex) as commentCount 7 | FROM comments 8 | WHERE state = 'approved' 9 | GROUP BY (domain, path) 10 | ) as subquery; 11 | -------------------------------------------------------------------------------- /frontend/js/constants.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | if (window.commento === undefined) { 7 | window.commento = {}; 8 | } 9 | 10 | window.commento.origin = "[[[.Origin]]]"; 11 | window.commento.cdn = "[[[.CdnPrefix]]]"; 12 | 13 | } (window, document)); 14 | -------------------------------------------------------------------------------- /api/owner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type owner struct { 8 | OwnerHex string `json:"ownerHex"` 9 | Email string `json:"email"` 10 | Name string `json:"name"` 11 | ConfirmedEmail bool `json:"confirmedEmail"` 12 | JoinDate time.Time `json:"joinDate"` 13 | } 14 | -------------------------------------------------------------------------------- /api/commenter_session_new_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommenterTokenNewBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if _, err := commenterTokenNew(); err != nil { 11 | t.Errorf("unexpected error creating new commenterToken: %v", err) 12 | return 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /api/utils_misc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func concat(a bytes.Buffer, b bytes.Buffer) []byte { 10 | return append(a.Bytes(), b.Bytes()...) 11 | } 12 | 13 | func exitIfError(err error) { 14 | if err != nil { 15 | fmt.Printf("fatal error: %v\n", err) 16 | os.Exit(1) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/utils_sql.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | // scanner is a database/sql abstraction interface that can be used with both 6 | // *sql.Row and *sql.Rows. 7 | type sqlScanner interface { 8 | // Scan copies columns from the underlying query row(s) to the values 9 | // pointed to by dest. 10 | Scan(dest ...interface{}) error 11 | } 12 | -------------------------------------------------------------------------------- /api/utils_gzip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | ) 7 | 8 | func gzipStatic(b []byte) ([]byte, error) { 9 | var buf bytes.Buffer 10 | g := gzip.NewWriter(&buf) 11 | if _, err := g.Write(b); err != nil { 12 | g.Close() 13 | return []byte{}, err 14 | } 15 | 16 | g.Close() 17 | return buf.Bytes(), nil 18 | } 19 | -------------------------------------------------------------------------------- /templates/domain-export.txt: -------------------------------------------------------------------------------- 1 | You recently requested a data export of your Commento domain {{.Domain}}. You 2 | can download a GZipped archive of a JSON export of all the comments and 3 | commenters associated with the domain here: 4 | 5 | {{.Origin}}/api/domain/export/download?exportHex={{.ExportHex}} 6 | 7 | The archive will be available for download for 7 days. 8 | -------------------------------------------------------------------------------- /api/page.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | type page struct { 6 | Domain string `json:"domain"` 7 | Path string `json:"path"` 8 | IsLocked bool `json:"isLocked"` 9 | CommentCount int `json:"commentCount"` 10 | StickyCommentHex string `json:"stickyCommentHex"` 11 | Title string `json:"title"` 12 | } 13 | -------------------------------------------------------------------------------- /db/20190420231030-sso-tokens.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS ssoTokens ( 2 | token TEXT NOT NULL UNIQUE PRIMARY KEY , 3 | domain TEXT NOT NULL , 4 | commenterToken TEXT NOT NULL , 5 | creationDate TIMESTAMP NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /api/utils_logging.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/op/go-logging" 5 | ) 6 | 7 | var logger *logging.Logger 8 | 9 | func loggerCreate() error { 10 | format := logging.MustStringFormatter("[%{level}] %{shortfile} %{shortfunc}(): %{message}") 11 | logging.SetFormatter(format) 12 | logger = logging.MustGetLogger("commento") 13 | 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /api/utils_crypto.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | ) 7 | 8 | func randomHex(n int) (string, error) { 9 | b := make([]byte, n) 10 | if _, err := rand.Read(b); err != nil { 11 | logger.Errorf("cannot create %d-byte long random hex: %v\n", n, err) 12 | return "", errorInternal 13 | } 14 | 15 | return hex.EncodeToString(b), nil 16 | } 17 | -------------------------------------------------------------------------------- /db/20180922181651-page-attributes.sql: -------------------------------------------------------------------------------- 1 | -- Introduces page attributes 2 | 3 | CREATE TABLE IF NOT EXISTS pages ( 4 | domain TEXT NOT NULL , 5 | path TEXT NOT NULL , 6 | isLocked BOOLEAN NOT NULL DEFAULT false 7 | ); 8 | 9 | CREATE UNIQUE INDEX pagesUniqueIndex ON pages(domain, path); 10 | -------------------------------------------------------------------------------- /api/utils_logging_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLoggerCreateBasics(t *testing.T) { 8 | logger = nil 9 | 10 | if err := loggerCreate(); err != nil { 11 | t.Errorf("unexpected error creating logger: %v", err) 12 | return 13 | } 14 | 15 | if logger == nil { 16 | t.Errorf("logger null after loggerCreate()") 17 | return 18 | } 19 | 20 | logger.Debugf("test message please ignore") 21 | } 22 | -------------------------------------------------------------------------------- /api/email.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type email struct { 8 | Email string `json:"email"` 9 | UnsubscribeSecretHex string `json:"unsubscribeSecretHex"` 10 | LastEmailNotificationDate time.Time `json:"lastEmailNotificationDate"` 11 | SendReplyNotifications bool `json:"sendReplyNotifications"` 12 | SendModeratorNotifications bool `json:"sendModeratorNotifications"` 13 | } 14 | -------------------------------------------------------------------------------- /api/domain_view_record.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //import ( 4 | // "time" 5 | //) 6 | 7 | func domainViewRecord(domain string, commenterHex string) { 8 | // statement := ` 9 | // INSERT INTO 10 | // views (domain, commenterHex, viewDate) 11 | // VALUES ($1, $2, $3 ); 12 | //` 13 | // _, err := db.Exec(statement, domain, commenterHex, time.Now().UTC()) 14 | // if err != nil { 15 | // logger.Warningf("cannot insert views: %v", err) 16 | // } 17 | } 18 | -------------------------------------------------------------------------------- /api/commenter_session.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // A session is a 3-field entry of a token, a hex, and a creation date. Do 8 | // not confuse session and token; the token is just an identifying string, 9 | // while the session contains more information. 10 | type commenterSession struct { 11 | CommenterToken string `json:"commenterToken"` 12 | CommenterHex string `json:"commenterHex"` 13 | CreationDate time.Time `json:"creationDate"` 14 | } 15 | -------------------------------------------------------------------------------- /etc/linux-systemd/commento.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Commento daemon service 3 | After=network.target postgresql.service 4 | 5 | [Service] 6 | Type=simple 7 | ExecStart=/usr/bin/commento 8 | Environment=COMMENTO_ORIGIN=https://commento.example.com 9 | Environment=COMMENTO_PORT=8080 10 | Environment=COMMENTO_POSTGRES=postgres://commento:commento@db:5432/commento?sslmode=disable 11 | Environment=COMMENTO_STATIC=/usr/share/commento 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /api/cron_views_cleanup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func viewsCleanupBegin() error { 8 | go func() { 9 | for { 10 | statement := ` 11 | DELETE FROM views 12 | WHERE viewDate < $1; 13 | ` 14 | _, err := db.Exec(statement, time.Now().UTC().AddDate(0, 0, -45)) 15 | if err != nil { 16 | logger.Errorf("error cleaning up views: %v", err) 17 | return 18 | } 19 | 20 | time.Sleep(24 * time.Hour) 21 | } 22 | }() 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /api/commenter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type commenter struct { 8 | CommenterHex string `json:"commenterHex,omitempty"` 9 | Email string `json:"email,omitempty"` 10 | Name string `json:"name"` 11 | Link string `json:"link"` 12 | Photo string `json:"photo"` 13 | Provider string `json:"provider,omitempty"` 14 | JoinDate time.Time `json:"joinDate,omitempty"` 15 | IsModerator bool `json:"isModerator"` 16 | } 17 | -------------------------------------------------------------------------------- /db/20190505191006-comment-count-decrease.sql: -------------------------------------------------------------------------------- 1 | -- This trigger is called every time a comment is deleted, so the comment count for the page where the comment belong is updated 2 | CREATE OR REPLACE FUNCTION commentsDeleteTriggerFunction() RETURNS TRIGGER AS $trigger$ 3 | BEGIN 4 | UPDATE pages 5 | SET commentCount = commentCount - 1 6 | WHERE domain = old.domain AND path = old.path; 7 | 8 | DELETE FROM comments 9 | WHERE parentHex = old.commentHex; 10 | 11 | RETURN NEW; 12 | END; 13 | $trigger$ LANGUAGE plpgsql; 14 | -------------------------------------------------------------------------------- /api/cron_domain_export_cleanup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func domainExportCleanupBegin() error { 8 | go func() { 9 | for { 10 | statement := ` 11 | DELETE FROM exports 12 | WHERE creationDate < $1; 13 | ` 14 | _, err := db.Exec(statement, time.Now().UTC().AddDate(0, 0, -7)) 15 | if err != nil { 16 | logger.Errorf("error cleaning up export rows: %v", err) 17 | return 18 | } 19 | 20 | time.Sleep(2 * time.Hour) 21 | } 22 | }() 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /api/page_new.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func pageNew(domain string, path string) error { 6 | // path can be empty 7 | if domain == "" { 8 | return errorMissingField 9 | } 10 | 11 | statement := ` 12 | INSERT INTO 13 | pages (domain, path) 14 | VALUES ($1, $2 ) 15 | ON CONFLICT DO NOTHING; 16 | ` 17 | _, err := db.Exec(statement, domain, path) 18 | if err != nil { 19 | logger.Errorf("error inserting new page: %v", err) 20 | return errorInternal 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /db/20180923002745-comment-count.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE pages 2 | ADD commentCount INTEGER NOT NULL DEFAULT 0; 3 | 4 | CREATE OR REPLACE FUNCTION commentsInsertTriggerFunction() RETURNS TRIGGER AS $trigger$ 5 | BEGIN 6 | UPDATE pages 7 | SET commentCount = commentCount + 1 8 | WHERE domain = new.domain AND path = new.path; 9 | 10 | RETURN NEW; 11 | END; 12 | $trigger$ LANGUAGE plpgsql; 13 | 14 | CREATE TRIGGER commentsInsertTrigger AFTER INSERT ON comments 15 | FOR EACH ROW EXECUTE PROCEDURE commentsInsertTriggerFunction(); 16 | -------------------------------------------------------------------------------- /db/20190418210855-configurable-auth.sql: -------------------------------------------------------------------------------- 1 | -- Make all login providers optional (but enabled by default) 2 | 3 | ALTER TABLE domains 4 | ADD commentoProvider BOOLEAN NOT NULL DEFAULT true; 5 | 6 | ALTER TABLE domains 7 | ADD googleProvider BOOLEAN NOT NULL DEFAULT true; 8 | 9 | ALTER TABLE domains 10 | ADD twitterProvider BOOLEAN NOT NULL DEFAULT true; 11 | 12 | ALTER TABLE domains 13 | ADD githubProvider BOOLEAN NOT NULL DEFAULT true; 14 | 15 | ALTER TABLE domains 16 | ADD gitlabProvider BOOLEAN NOT NULL DEFAULT true; 17 | -------------------------------------------------------------------------------- /api/cron_sso_token.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func ssoTokenCleanupBegin() error { 8 | go func() { 9 | for { 10 | statement := ` 11 | DELETE FROM ssoTokens 12 | WHERE creationDate < $1; 13 | ` 14 | _, err := db.Exec(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute)) 15 | if err != nil { 16 | logger.Errorf("error cleaning up export rows: %v", err) 17 | return 18 | } 19 | 20 | time.Sleep(10 * time.Minute) 21 | } 22 | }() 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /scripts/gitlab-ci-build-prescript: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mkdir -p /go/src /go/bin /go/pkg 4 | ln -s $CI_PROJECT_DIR /go/src/$CI_PROJECT_NAME 5 | 6 | apt update 7 | apt install -y curl gnupg git make golang python 8 | export GOPATH=/go 9 | export PATH=$PATH:/go/bin 10 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 11 | 12 | curl -sL https://deb.nodesource.com/setup_10.x | bash - 13 | apt install -y nodejs 14 | npm install -g yarn@1.10.0 15 | 16 | apt install -y python python-pip 17 | pip install awscli 18 | -------------------------------------------------------------------------------- /api/utils_sanitise_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestEmailStripBasics(t *testing.T) { 8 | tests := map[string]string{ 9 | "test@example.com": "test@example.com", 10 | "test+strip@example.com": "test@example.com", 11 | "test+strip+strip2@example.com": "test@example.com", 12 | } 13 | 14 | for in, out := range tests { 15 | if emailStrip(in) != out { 16 | t.Errorf("for in=%s expected out=%s got out=%s", in, out, emailStrip(in)) 17 | return 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/oauth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | var googleConfigured bool 6 | var twitterConfigured bool 7 | var githubConfigured bool 8 | var gitlabConfigured bool 9 | 10 | func oauthConfigure() error { 11 | if err := googleOauthConfigure(); err != nil { 12 | return err 13 | } 14 | 15 | if err := twitterOauthConfigure(); err != nil { 16 | return err 17 | } 18 | 19 | if err := githubOauthConfigure(); err != nil { 20 | return err 21 | } 22 | 23 | if err := gitlabOauthConfigure(); err != nil { 24 | return err 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:1.9 6 | 7 | working_directory: /go/src/github.com/{{ORG_NAME}}/{{REPO_NAME}} 8 | triggers: 9 | - schedule: 10 | cron: "0 0 * * *" 11 | filters: 12 | branches: 13 | only: 14 | - autoupdate 15 | steps: 16 | - checkout 17 | - add_ssh_keys: 18 | fingerprints: 19 | - "3c:e1:34:34:88:c6:c4:e0:b3:72:e5:dd:7d:bd:ef:d9" 20 | - run: bash update_repo.sh -xe 21 | 22 | -------------------------------------------------------------------------------- /api/commenter_session_update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func commenterSessionUpdate(commenterToken string, commenterHex string) error { 6 | if commenterToken == "" || commenterHex == "" { 7 | return errorMissingField 8 | } 9 | 10 | statement := ` 11 | UPDATE commenterSessions 12 | SET commenterHex = $2 13 | WHERE commenterToken = $1; 14 | ` 15 | _, err := db.Exec(statement, commenterToken, commenterHex) 16 | if err != nil { 17 | logger.Errorf("error updating commenterHex: %v", err) 18 | return errorInternal 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /api/comment_domain_path_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func commentDomainPathGet(commentHex string) (string, string, error) { 6 | if commentHex == "" { 7 | return "", "", errorMissingField 8 | } 9 | 10 | statement := ` 11 | SELECT domain, path 12 | FROM comments 13 | WHERE commentHex = $1; 14 | ` 15 | row := db.QueryRow(statement, commentHex) 16 | 17 | var domain string 18 | var path string 19 | var err error 20 | if err = row.Scan(&domain, &path); err != nil { 21 | return "", "", errorNoSuchDomain 22 | } 23 | 24 | return domain, path, nil 25 | } 26 | -------------------------------------------------------------------------------- /frontend/sass/commento-mod-tools.scss: -------------------------------------------------------------------------------- 1 | @import "colors-main.scss"; 2 | 3 | .commento-mod-tools { 4 | margin-bottom: 16px; 5 | 6 | button { 7 | text-transform: uppercase; 8 | color: $gray-7; 9 | font-size: 12px; 10 | font-weight: 700; 11 | cursor: pointer; 12 | margin-left: 12px; 13 | background: none; 14 | border: none; 15 | display: inline; 16 | } 17 | } 18 | 19 | .commento-mod-tools::before { 20 | content: "Moderator Tools"; 21 | text-transform: uppercase; 22 | color: $indigo-8; 23 | font-size: 12px; 24 | font-weight: 700; 25 | } 26 | -------------------------------------------------------------------------------- /api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | exitIfError(loggerCreate()) 5 | exitIfError(versionPrint()) 6 | exitIfError(configParse()) 7 | exitIfError(dbConnect(5)) 8 | exitIfError(migrate()) 9 | exitIfError(smtpConfigure()) 10 | exitIfError(smtpTemplatesLoad()) 11 | exitIfError(oauthConfigure()) 12 | exitIfError(markdownRendererCreate()) 13 | exitIfError(sigintCleanupSetup()) 14 | exitIfError(versionCheckStart()) 15 | exitIfError(domainExportCleanupBegin()) 16 | exitIfError(viewsCleanupBegin()) 17 | exitIfError(ssoTokenCleanupBegin()) 18 | 19 | exitIfError(routesServe()) 20 | } 21 | -------------------------------------------------------------------------------- /api/sigint.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | ) 8 | 9 | func sigintCleanup() int { 10 | if db != nil { 11 | err := db.Close() 12 | if err == nil { 13 | logger.Errorf("cannot close database connection: %v", err) 14 | return 1 15 | } 16 | } 17 | 18 | return 0 19 | } 20 | 21 | func sigintCleanupSetup() error { 22 | logger.Infof("setting up SIGINT cleanup") 23 | 24 | c := make(chan os.Signal) 25 | signal.Notify(c, os.Interrupt, syscall.SIGINT) 26 | go func() { 27 | <-c 28 | os.Exit(sigintCleanup()) 29 | }() 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /api/utils_crypto_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestRandomHexBasics(t *testing.T) { 8 | hex1, err := randomHex(32) 9 | if err != nil { 10 | t.Errorf("unexpected error creating hex: %v", err) 11 | return 12 | } 13 | 14 | if hex1 == "" { 15 | t.Errorf("randomly generated hex empty") 16 | return 17 | } 18 | 19 | hex2, err := randomHex(32) 20 | if err != nil { 21 | t.Errorf("unexpected error creating hex: %v", err) 22 | return 23 | } 24 | 25 | if hex1 == hex2 { 26 | t.Errorf("two randomly generated hexes found to be the same: '%s'", hex1) 27 | return 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /api/domain_update_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainUpdateBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | domainNew("temp-owner-hex", "Example", "example.com") 11 | 12 | d, _ := domainList("temp-owner-hex") 13 | 14 | d[0].Name = "Example2" 15 | 16 | if err := domainUpdate(d[0]); err != nil { 17 | t.Errorf("unexpected error updating domain: %v", err) 18 | return 19 | } 20 | 21 | d, _ = domainList("temp-owner-hex") 22 | 23 | if d[0].Name != "Example2" { 24 | t.Errorf("expected name=Example2 got name=%s", d[0].Name) 25 | return 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/domain_ownership_verify.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func domainOwnershipVerify(ownerHex string, domain string) (bool, error) { 6 | if ownerHex == "" || domain == "" { 7 | return false, errorMissingField 8 | } 9 | 10 | statement := ` 11 | SELECT EXISTS ( 12 | SELECT 1 13 | FROM domains 14 | WHERE ownerHex=$1 AND domain=$2 15 | ); 16 | ` 17 | row := db.QueryRow(statement, ownerHex, domain) 18 | 19 | var exists bool 20 | if err := row.Scan(&exists); err != nil { 21 | logger.Errorf("cannot query if domain owner: %v", err) 22 | return false, errorInternal 23 | } 24 | 25 | return exists, nil 26 | } 27 | -------------------------------------------------------------------------------- /frontend/js/dashboard-installation.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | // Opens the installation view. 7 | global.installationOpen = function() { 8 | var html = "" + 9 | " 5 | 6 | 7 | Commento: Email Confirmation 8 | 9 | 10 | 15 | 16 |
17 |
18 |
19 | Confirmation Email Sent! 20 |
21 | A confirmation email has been sent to your email address. Follow the instructions by clicking the link there to complete the signup process. 22 |
23 |
24 | 25 | [[[.Footer]]] 26 | 27 | -------------------------------------------------------------------------------- /api/comment_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | var commentsRowColumns = ` 6 | comments.commentHex, 7 | comments.commenterHex, 8 | comments.markdown, 9 | comments.html, 10 | comments.parentHex, 11 | comments.score, 12 | comments.state, 13 | comments.deleted, 14 | comments.creationDate 15 | ` 16 | 17 | func commentsRowScan(s sqlScanner, c *comment) error { 18 | return s.Scan( 19 | &c.CommentHex, 20 | &c.CommenterHex, 21 | &c.Markdown, 22 | &c.Html, 23 | &c.ParentHex, 24 | &c.Score, 25 | &c.State, 26 | &c.Deleted, 27 | &c.CreationDate, 28 | ) 29 | } 30 | 31 | func commentGetByCommentHex(commentHex string) (comment, error) { 32 | if commentHex == "" { 33 | return comment{}, errorMissingField 34 | } 35 | 36 | statement := ` 37 | SELECT ` + commentsRowColumns + ` 38 | FROM comments 39 | WHERE comments.commentHex = $1; 40 | ` 41 | row := db.QueryRow(statement, commentHex) 42 | 43 | var c comment 44 | if err := commentsRowScan(row, &c); err != nil { 45 | // TODO: is this the only error? 46 | return c, errorNoSuchComment 47 | } 48 | 49 | return c, nil 50 | } 51 | -------------------------------------------------------------------------------- /api/page_get_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPageGetBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | pageNew("example.com", "/path.html") 11 | 12 | p, err := pageGet("example.com", "/path.html") 13 | if err != nil { 14 | t.Errorf("unexpected error getting page: %v", err) 15 | return 16 | } 17 | 18 | if p.IsLocked != false { 19 | t.Errorf("expected p.IsLocked=false got %v", p.IsLocked) 20 | return 21 | } 22 | 23 | if _, err := pageGet("example.com", "/path2.html"); err != nil { 24 | t.Errorf("unexpected error getting page with non-existant record: %v", err) 25 | return 26 | } 27 | } 28 | 29 | func TestPageGetEmpty(t *testing.T) { 30 | failTestOnError(t, setupTestEnv()) 31 | 32 | pageNew("example.com", "") 33 | 34 | if _, err := pageGet("example.com", ""); err != nil { 35 | t.Errorf("unexpected error getting page with empty path: %v", err) 36 | return 37 | } 38 | 39 | if _, err := pageGet("", "/path.html"); err == nil { 40 | t.Errorf("exepected error not found when getting page with empty domain") 41 | return 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/oauth_github.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "golang.org/x/oauth2" 5 | "golang.org/x/oauth2/github" 6 | "os" 7 | ) 8 | 9 | var githubConfig *oauth2.Config 10 | 11 | func githubOauthConfigure() error { 12 | githubConfig = nil 13 | if os.Getenv("GITHUB_KEY") == "" && os.Getenv("GITHUB_SECRET") == "" { 14 | return nil 15 | } 16 | 17 | if os.Getenv("GITHUB_KEY") == "" { 18 | logger.Errorf("COMMENTO_GITHUB_KEY not configured, but COMMENTO_GITHUB_SECRET is set") 19 | return errorOauthMisconfigured 20 | } 21 | 22 | if os.Getenv("GITHUB_SECRET") == "" { 23 | logger.Errorf("COMMENTO_GITHUB_SECRET not configured, but COMMENTO_GITHUB_KEY is set") 24 | return errorOauthMisconfigured 25 | } 26 | 27 | logger.Infof("loading github OAuth config") 28 | 29 | githubConfig = &oauth2.Config{ 30 | RedirectURL: os.Getenv("ORIGIN") + "/api/oauth/github/callback", 31 | ClientID: os.Getenv("GITHUB_KEY"), 32 | ClientSecret: os.Getenv("GITHUB_SECRET"), 33 | Scopes: []string{ 34 | "read:user", 35 | "user:email", 36 | }, 37 | Endpoint: github.Endpoint, 38 | } 39 | 40 | githubConfigured = true 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /frontend/sass/commento-oauth.scss: -------------------------------------------------------------------------------- 1 | @import "colors-main.scss"; 2 | 3 | .commento-oauth-buttons-container { 4 | display: flex; 5 | justify-content: center; 6 | 7 | .commento-oauth-buttons { 8 | align-items: center; 9 | position: absolute; 10 | z-index: 1; 11 | display: contents; 12 | 13 | .commento-google-button { 14 | background: #dd4b39; 15 | text-transform: uppercase; 16 | font-size: 13px; 17 | width: 70px; 18 | } 19 | 20 | .commento-github-button { 21 | background: #000000; 22 | text-transform: uppercase; 23 | font-size: 13px; 24 | width: 70px; 25 | } 26 | 27 | .commento-twitter-button { 28 | background: #00aced; 29 | text-transform: uppercase; 30 | font-size: 13px; 31 | width: 70px; 32 | } 33 | 34 | .commento-gitlab-button { 35 | background: #fc6d26; 36 | text-transform: uppercase; 37 | font-size: 13px; 38 | width: 70px; 39 | } 40 | 41 | .commento-sso-button { 42 | background: #000000; 43 | text-transform: uppercase; 44 | font-size: 13px; 45 | width: 200px; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /api/hub.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Hub maintains the set of active clients and broadcasts messages to the 4 | // clients. 5 | type Hub struct { 6 | // Registered clients. 7 | clients map[*Client]bool 8 | 9 | // Inbound messages from the clients. 10 | broadcast chan []byte 11 | 12 | // Register requests from the clients. 13 | register chan *Client 14 | 15 | // Unregister requests from clients. 16 | unregister chan *Client 17 | } 18 | 19 | func newHub() *Hub { 20 | return &Hub{ 21 | broadcast: make(chan []byte), 22 | register: make(chan *Client), 23 | unregister: make(chan *Client), 24 | clients: make(map[*Client]bool), 25 | } 26 | } 27 | 28 | func (h *Hub) run() { 29 | for { 30 | select { 31 | case client := <-h.register: 32 | h.clients[client] = true 33 | case client := <-h.unregister: 34 | if _, ok := h.clients[client]; ok { 35 | delete(h.clients, client) 36 | close(client.send) 37 | } 38 | case message := <-h.broadcast: 39 | for client := range h.clients { 40 | select { 41 | case client.send <- message: 42 | default: 43 | close(client.send) 44 | delete(h.clients, client) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/page_new_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPageNewBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if err := pageNew("example.com", "/path.html"); err != nil { 11 | t.Errorf("unexpected error creating page: %v", err) 12 | return 13 | } 14 | } 15 | 16 | func TestPageNewEmpty(t *testing.T) { 17 | failTestOnError(t, setupTestEnv()) 18 | 19 | if err := pageNew("example.com", ""); err != nil { 20 | t.Errorf("unexpected error creating page with empty path: %v", err) 21 | return 22 | } 23 | 24 | if err := pageNew("", "/path.html"); err == nil { 25 | t.Errorf("expected error not found creating page with empty domain") 26 | return 27 | } 28 | } 29 | 30 | func TestPageNewUnique(t *testing.T) { 31 | failTestOnError(t, setupTestEnv()) 32 | 33 | if err := pageNew("example.com", "/path.html"); err != nil { 34 | t.Errorf("unexpected error creating page: %v", err) 35 | return 36 | } 37 | 38 | // no error should be returned when trying to duplicate insert 39 | if err := pageNew("example.com", "/path.html"); err != nil { 40 | t.Errorf("unexpected error creating same page twice: %v", err) 41 | return 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /templates/Makefile: -------------------------------------------------------------------------------- 1 | SHELL = bash 2 | 3 | BUILD_DIR = build 4 | DEVEL_BUILD_DIR = $(BUILD_DIR)/devel 5 | PROD_BUILD_DIR = $(BUILD_DIR)/prod 6 | 7 | TEMPLATES_SRC_DIR = . 8 | TEMPLATES_SRC_FILES = $(wildcard $(TEMPLATES_SRC_DIR)/*.txt) 9 | TEMPLATES_DEVEL_BUILD_DIR = $(DEVEL_BUILD_DIR)/templates 10 | TEMPLATES_DEVEL_BUILD_FILES = $(patsubst $(TEMPLATES_SRC_DIR)/%, $(TEMPLATES_DEVEL_BUILD_DIR)/%, $(TEMPLATES_SRC_FILES)) 11 | TEMPLATES_PROD_BUILD_DIR = $(PROD_BUILD_DIR)/templates 12 | TEMPLATES_PROD_BUILD_FILES = $(patsubst $(TEMPLATES_SRC_DIR)/%, $(TEMPLATES_PROD_BUILD_DIR)/%, $(TEMPLATES_SRC_FILES)) 13 | 14 | devel: devel-templates 15 | 16 | prod: prod-templates 17 | 18 | clean: 19 | rm -rf $(BUILD_DIR) 20 | 21 | devel-templates: $(TEMPLATES_DEVEL_BUILD_FILES) 22 | 23 | $(TEMPLATES_DEVEL_BUILD_FILES): $(TEMPLATES_DEVEL_BUILD_DIR)/%.txt: $(TEMPLATES_SRC_DIR)/%.txt 24 | cp $^ $@; 25 | 26 | prod-templates: $(TEMPLATES_PROD_BUILD_FILES) 27 | 28 | $(TEMPLATES_PROD_BUILD_FILES): $(TEMPLATES_PROD_BUILD_DIR)/%.txt: $(TEMPLATES_SRC_DIR)/%.txt 29 | cp $^ $@; 30 | 31 | $(shell mkdir -p $(TEMPLATES_DEVEL_BUILD_DIR) $(TEMPLATES_PROD_BUILD_DIR)) 32 | -------------------------------------------------------------------------------- /api/owner_new_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestOwnerNewBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if _, err := ownerNew("test@example.com", "Test", "hunter2"); err != nil { 11 | t.Errorf("unexpected error when creating new owner: %v", err) 12 | return 13 | } 14 | } 15 | 16 | func TestOwnerNewClash(t *testing.T) { 17 | failTestOnError(t, setupTestEnv()) 18 | 19 | if _, err := ownerNew("test@example.com", "Test", "hunter2"); err != nil { 20 | t.Errorf("unexpected error when creating new owner: %v", err) 21 | return 22 | } 23 | 24 | if _, err := ownerNew("test@example.com", "Test", "hunter2"); err == nil { 25 | t.Errorf("expected error not found when creating with clashing email") 26 | return 27 | } 28 | } 29 | 30 | func TestOwnerNewEmpty(t *testing.T) { 31 | failTestOnError(t, setupTestEnv()) 32 | 33 | if _, err := ownerNew("test@example.com", "", "hunter2"); err == nil { 34 | t.Errorf("expected error not found when passing empty name") 35 | return 36 | } 37 | 38 | if _, err := ownerNew("", "", ""); err == nil { 39 | t.Errorf("expected error not found when passing empty everything") 40 | return 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/domain_new_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainNewBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if err := domainNew("temp-owner-hex", "Example", "example.com"); err != nil { 11 | t.Errorf("unexpected error creating domain: %v", err) 12 | return 13 | } 14 | } 15 | 16 | func TestDomainNewClash(t *testing.T) { 17 | failTestOnError(t, setupTestEnv()) 18 | 19 | if err := domainNew("temp-owner-hex", "Example", "example.com"); err != nil { 20 | t.Errorf("unexpected error creating domain: %v", err) 21 | return 22 | } 23 | 24 | if err := domainNew("temp-owner-hex", "Example 2", "example.com"); err == nil { 25 | t.Errorf("expected error not found when creating with clashing domain") 26 | return 27 | } 28 | } 29 | 30 | func TestDomainNewEmpty(t *testing.T) { 31 | failTestOnError(t, setupTestEnv()) 32 | 33 | if err := domainNew("temp-owner-hex", "Example", ""); err == nil { 34 | t.Errorf("expected error not found when creating with emtpy domain") 35 | return 36 | } 37 | 38 | if err := domainNew("", "", ""); err == nil { 39 | t.Errorf("expected error not found when creating with emtpy everything") 40 | return 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/oauth_twitter_redirect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | func twitterRedirectHandler(w http.ResponseWriter, r *http.Request) { 10 | if twitterClient == nil { 11 | logger.Errorf("twitter oauth access attempt without configuration") 12 | fmt.Fprintf(w, "error: this website has not configured twitter OAuth") 13 | return 14 | } 15 | 16 | commenterToken := r.FormValue("commenterToken") 17 | 18 | _, err := commenterGetByCommenterToken(commenterToken) 19 | if err != nil && err != errorNoSuchToken { 20 | fmt.Fprintf(w, "error: %s\n", err.Error()) 21 | return 22 | } 23 | 24 | cred, err := twitterClient.RequestTemporaryCredentials(nil, os.Getenv("ORIGIN")+"/api/oauth/twitter/callback", nil) 25 | if err != nil { 26 | logger.Errorf("cannot get temporary twitter credentials: %v", err) 27 | fmt.Fprintf(w, "error: %v", errorInternal.Error()) 28 | return 29 | } 30 | 31 | twitterCredMapLock.Lock() 32 | twitterCredMap[cred.Token] = twitterOauthState{ 33 | CommenterToken: commenterToken, 34 | Cred: cred, 35 | } 36 | twitterCredMapLock.Unlock() 37 | 38 | http.Redirect(w, r, twitterClient.AuthorizationURL(cred, nil), http.StatusFound) 39 | } 40 | -------------------------------------------------------------------------------- /api/page_update_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestPageUpdateBasics(t *testing.T) { 9 | failTestOnError(t, setupTestEnv()) 10 | 11 | commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google", "") 12 | 13 | commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC()) 14 | 15 | p, _ := pageGet("example.com", "/path.html") 16 | if p.IsLocked != false { 17 | t.Errorf("expected IsLocked=false got %v", p.IsLocked) 18 | return 19 | } 20 | 21 | p.IsLocked = true 22 | 23 | if err := pageUpdate(p); err != nil { 24 | t.Errorf("unexpected error updating page: %v", err) 25 | return 26 | } 27 | 28 | p, _ = pageGet("example.com", "/path.html") 29 | if p.IsLocked != true { 30 | t.Errorf("expected IsLocked=true got %v", p.IsLocked) 31 | return 32 | } 33 | } 34 | 35 | func TestPageUpdateEmpty(t *testing.T) { 36 | failTestOnError(t, setupTestEnv()) 37 | 38 | p := page{Domain: "", Path: "", IsLocked: false} 39 | if err := pageUpdate(p); err == nil { 40 | t.Errorf("expected error not found updating page with empty everything") 41 | return 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/oauth_google.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "golang.org/x/oauth2" 5 | "golang.org/x/oauth2/google" 6 | "os" 7 | ) 8 | 9 | var googleConfig *oauth2.Config 10 | 11 | func googleOauthConfigure() error { 12 | googleConfig = nil 13 | if os.Getenv("GOOGLE_KEY") == "" && os.Getenv("GOOGLE_SECRET") == "" { 14 | return nil 15 | } 16 | 17 | if os.Getenv("GOOGLE_KEY") == "" { 18 | logger.Errorf("COMMENTO_GOOGLE_KEY not configured, but COMMENTO_GOOGLE_SECRET is set") 19 | return errorOauthMisconfigured 20 | } 21 | 22 | if os.Getenv("GOOGLE_SECRET") == "" { 23 | logger.Errorf("COMMENTO_GOOGLE_SECRET not configured, but COMMENTO_GOOGLE_KEY is set") 24 | return errorOauthMisconfigured 25 | } 26 | 27 | logger.Infof("loading Google OAuth config") 28 | 29 | googleConfig = &oauth2.Config{ 30 | RedirectURL: os.Getenv("ORIGIN") + "/api/oauth/google/callback", 31 | ClientID: os.Getenv("GOOGLE_KEY"), 32 | ClientSecret: os.Getenv("GOOGLE_SECRET"), 33 | Scopes: []string{ 34 | "https://www.googleapis.com/auth/userinfo.profile", 35 | "https://www.googleapis.com/auth/userinfo.email", 36 | }, 37 | Endpoint: google.Endpoint, 38 | } 39 | 40 | googleConfigured = true 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /api/commenter_new_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommenterNewBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if _, err := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google", ""); err != nil { 11 | t.Errorf("unexpected error creating new commenter: %v", err) 12 | return 13 | } 14 | } 15 | 16 | func TestCommenterNewEmpty(t *testing.T) { 17 | failTestOnError(t, setupTestEnv()) 18 | 19 | if _, err := commenterNew("", "Test", "undefined", "https://example.com/photo.jpg", "google", ""); err == nil { 20 | t.Errorf("expected error not found creating new commenter with empty email") 21 | return 22 | } 23 | 24 | if _, err := commenterNew("", "", "", "", "", ""); err == nil { 25 | t.Errorf("expected error not found creating new commenter with empty everything") 26 | return 27 | } 28 | } 29 | 30 | func TestCommenterNewCommento(t *testing.T) { 31 | failTestOnError(t, setupTestEnv()) 32 | 33 | if _, err := commenterNew("test@example.com", "Test", "undefined", "", "commento", ""); err == nil { 34 | t.Errorf("expected error not found creating new commento account with empty password") 35 | return 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/commenter_session_update_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommenterSessionUpdateBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | commenterToken, _ := commenterTokenNew() 11 | 12 | if err := commenterSessionUpdate(commenterToken, "temp-commenter-hex"); err != nil { 13 | t.Errorf("unexpected error updating commenter session: %v", err) 14 | return 15 | } 16 | 17 | statement := ` 18 | SELECT commenterHex 19 | FROM commenterSessions 20 | WHERE commenterToken = $1; 21 | ` 22 | row := db.QueryRow(statement, commenterToken) 23 | 24 | var commenterHex string 25 | if err := row.Scan(&commenterHex); err != nil { 26 | t.Errorf("error scanning commenterHex: %v", err) 27 | return 28 | } 29 | 30 | if commenterHex != "temp-commenter-hex" { 31 | t.Errorf("expected commenterHex=temp-commenter-hex got commenterHex=%s", commenterHex) 32 | return 33 | } 34 | } 35 | 36 | func TestCommenterSessionUpdateEmpty(t *testing.T) { 37 | failTestOnError(t, setupTestEnv()) 38 | 39 | if err := commenterSessionUpdate("", "temp-commenter-hex"); err == nil { 40 | t.Errorf("expected error not found when updating with empty commenterToken") 41 | return 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/comment_ownership_verify_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestCommentOwnershipVerifyBasics(t *testing.T) { 9 | failTestOnError(t, setupTestEnv()) 10 | 11 | commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC()) 12 | 13 | isOwner, err := commentOwnershipVerify("temp-commenter-hex", commentHex) 14 | if err != nil { 15 | t.Errorf("unexpected error verifying ownership: %v", err) 16 | return 17 | } 18 | 19 | if !isOwner { 20 | t.Errorf("expected to be owner of comment") 21 | return 22 | } 23 | 24 | isOwner, err = commentOwnershipVerify("another-commenter-hex", commentHex) 25 | if err != nil { 26 | t.Errorf("unexpected error verifying ownership: %v", err) 27 | return 28 | } 29 | 30 | if isOwner { 31 | t.Errorf("unexpected owner of comment not created by another-commenter-hex") 32 | return 33 | } 34 | } 35 | 36 | func TestCommentOwnershipVerifyEmpty(t *testing.T) { 37 | failTestOnError(t, setupTestEnv()) 38 | 39 | if _, err := commentOwnershipVerify("temp-commenter-hex", ""); err == nil { 40 | t.Errorf("expected error not founding verifying ownership with empty commentHex") 41 | return 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/markdown_html_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMarkdownToHtmlBasics(t *testing.T) { 9 | failTestOnError(t, setupTestEnv()) 10 | 11 | // basic markdown and expected html tests 12 | tests := map[string]string{ 13 | "Foo": "

Foo

", 14 | 15 | "Foo\n\nBar": "

Foo

\n\n

Bar

", 16 | 17 | "XSS: Foo": "

XSS: Foo

", 18 | 19 | "Regular [Link](http://example.com)": "

Regular Link

", 20 | 21 | "XSS [Link](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pgo=)": "

XSS Link

", 22 | 23 | "![Images disallowed](http://example.com/image.jpg)": "

", 24 | 25 | "**bold** *italics*": "

bold italics

", 26 | 27 | "http://example.com/autolink": "

http://example.com/autolink

", 28 | 29 | "not bold": "

not bold

", 30 | } 31 | 32 | for in, out := range tests { 33 | html := strings.TrimSpace(markdownToHtml(in)) 34 | if html != out { 35 | t.Errorf("for in=[%s] expected out=[%s] got out=[%s]", in, out, html) 36 | return 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/js/dashboard-setting.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | // Sets the vue.js toggle to select and deselect panes visually. 7 | function settingSelectCSS(id) { 8 | var data = global.dashboard.$data; 9 | var settings = data.settings; 10 | 11 | for (var i = 0; i < settings.length; i++) { 12 | settings[i].selected = settings[i].id === id; 13 | } 14 | } 15 | 16 | 17 | // Selects a setting. 18 | global.settingSelect = function(id) { 19 | var data = global.dashboard.$data; 20 | var settings = data.settings; 21 | 22 | settingSelectCSS(id); 23 | 24 | $("ul.tabs li").removeClass("current"); 25 | $(".content").removeClass("current"); 26 | $(".original").addClass("current"); 27 | 28 | for (var i = 0; i < settings.length; i++) { 29 | if (id === settings[i].id) { 30 | settings[i].open(); 31 | } 32 | } 33 | }; 34 | 35 | 36 | // Deselects all settings. 37 | global.settingDeselectAll = function() { 38 | var data = global.dashboard.$data; 39 | var settings = data.settings; 40 | 41 | for (var i = 0; i < settings.length; i++) { 42 | settings[i].selected = false; 43 | } 44 | } 45 | 46 | } (window.commento, document)); 47 | -------------------------------------------------------------------------------- /api/Makefile: -------------------------------------------------------------------------------- 1 | SHELL = bash 2 | 3 | BUILD_DIR = build 4 | DEVEL_BUILD_DIR = $(BUILD_DIR)/devel 5 | PROD_BUILD_DIR = $(BUILD_DIR)/prod 6 | 7 | GO_SRC_DIR = . 8 | GO_SRC_FILES = $(wildcard $(GO_SRC_DIR)/*.go) 9 | GO_DEVEL_BUILD_DIR = $(DEVEL_BUILD_DIR) 10 | GO_DEVEL_BUILD_BINARY = $(GO_DEVEL_BUILD_DIR)/commento 11 | GO_PROD_BUILD_DIR = $(PROD_BUILD_DIR) 12 | GO_PROD_BUILD_BINARY = $(GO_PROD_BUILD_DIR)/commento 13 | 14 | devel: devel-go 15 | 16 | prod: prod-go 17 | 18 | test: test-go 19 | 20 | clean: 21 | rm -rf $(BUILD_DIR) 22 | 23 | # There's really no difference between the prod and devel binaries in Go, but 24 | # for consistency sake, we'll use separate targets (maybe this will be useful 25 | # later down the line). 26 | 27 | devel-go: 28 | GO111MODULE=on go mod vendor 29 | GO111MODULE=on go build -mod=vendor -v -o $(GO_DEVEL_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)" 30 | 31 | prod-go: 32 | GO111MODULE=on go mod vendor 33 | GO111MODULE=on go build -mod=vendor -v -o $(GO_PROD_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)" 34 | 35 | test-go: 36 | GO111MODULE=on go mod vendor 37 | go test -v . 38 | 39 | $(shell mkdir -p $(GO_DEVEL_BUILD_DIR) $(GO_PROD_BUILD_DIR)) 40 | -------------------------------------------------------------------------------- /api/oauth_gitlab.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "golang.org/x/oauth2" 5 | "golang.org/x/oauth2/gitlab" 6 | "os" 7 | ) 8 | 9 | var gitlabConfig *oauth2.Config 10 | 11 | func gitlabOauthConfigure() error { 12 | gitlabConfig = nil 13 | if os.Getenv("GITLAB_KEY") == "" && os.Getenv("GITLAB_SECRET") == "" { 14 | return nil 15 | } 16 | 17 | if os.Getenv("GITLAB_KEY") == "" { 18 | logger.Errorf("COMMENTO_GITLAB_KEY not configured, but COMMENTO_GITLAB_SECRET is set") 19 | return errorOauthMisconfigured 20 | } 21 | 22 | if os.Getenv("GITLAB_SECRET") == "" { 23 | logger.Errorf("COMMENTO_GITLAB_SECRET not configured, but COMMENTO_GITLAB_KEY is set") 24 | return errorOauthMisconfigured 25 | } 26 | 27 | logger.Infof("loading gitlab OAuth config") 28 | 29 | gitlabConfig = &oauth2.Config{ 30 | RedirectURL: os.Getenv("ORIGIN") + "/api/oauth/gitlab/callback", 31 | ClientID: os.Getenv("GITLAB_KEY"), 32 | ClientSecret: os.Getenv("GITLAB_SECRET"), 33 | Scopes: []string{ 34 | "read_user", 35 | }, 36 | Endpoint: gitlab.Endpoint, 37 | } 38 | gitlabConfig.Endpoint.AuthURL = os.Getenv("GITLAB_URL") + "/oauth/authorize" 39 | gitlabConfig.Endpoint.TokenURL = os.Getenv("GITLAB_URL") + "/oauth/token" 40 | 41 | gitlabConfigured = true 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /frontend/js/forgot.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | // Talks to the API and sends an reset email. 7 | global.sendResetHex = function(event) { 8 | event.preventDefault(); 9 | 10 | var allOk = global.unfilledMark(["#email"], function(el) { 11 | el.css("border-bottom", "1px solid red"); 12 | }); 13 | 14 | if (!allOk) { 15 | global.textSet("#err", "Please make sure all fields are filled."); 16 | return; 17 | } 18 | 19 | var entity = "owner"; 20 | if (global.paramGet("commenter") === "true") { 21 | entity = "commenter"; 22 | } 23 | 24 | var json = { 25 | "email": $("#email").val(), 26 | "entity": entity, 27 | }; 28 | 29 | global.buttonDisable("#reset-button"); 30 | global.post(global.origin + "/api/forgot", json, function(resp) { 31 | global.buttonEnable("#reset-button"); 32 | 33 | global.textSet("#err", ""); 34 | if (!resp.success) { 35 | global.textSet("#err", resp.message); 36 | return 37 | } 38 | 39 | $("#msg").html("If that email is a registered account, you will receive an email with instructions on how to reset your password."); 40 | $("#reset-button").hide(); 41 | }); 42 | } 43 | 44 | } (window.commento, document)); 45 | -------------------------------------------------------------------------------- /frontend/js/reset.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | global.resetPassword = function(event) { 5 | event.preventDefault(); 6 | 7 | var allOk = global.unfilledMark(["#password", "#password2"], function(el) { 8 | el.css("border-bottom", "1px solid red"); 9 | }); 10 | 11 | if (!allOk) { 12 | global.textSet("#err", "Please make sure all fields are filled."); 13 | return; 14 | } 15 | 16 | if ($("#password").val() !== $("#password2").val()) { 17 | global.textSet("#err", "The two passwords do not match."); 18 | return; 19 | } 20 | 21 | var json = { 22 | "resetHex": global.paramGet("hex"), 23 | "password": $("#password").val(), 24 | }; 25 | 26 | global.buttonDisable("#reset-button"); 27 | global.post(global.origin + "/api/reset", json, function(resp) { 28 | global.buttonEnable("#reset-button"); 29 | 30 | global.textSet("#err", ""); 31 | if (!resp.success) { 32 | global.textSet("#err", resp.message); 33 | return 34 | } 35 | 36 | if (resp.entity === "owner") { 37 | document.location = global.origin + "/login?changed=true"; 38 | } else { 39 | $("#msg").html("Your password has been reset. You may close this window and try logging in again."); 40 | } 41 | }); 42 | } 43 | 44 | } (window.commento, document)); 45 | -------------------------------------------------------------------------------- /api/utils_http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "reflect" 8 | ) 9 | 10 | type response map[string]interface{} 11 | 12 | // TODO: Add tests in utils_http_test.go 13 | 14 | func bodyUnmarshal(r *http.Request, x interface{}) error { 15 | b, err := ioutil.ReadAll(r.Body) 16 | if err != nil { 17 | logger.Errorf("cannot read POST body: %v\n", err) 18 | return errorInternal 19 | } 20 | 21 | if err = json.Unmarshal(b, x); err != nil { 22 | return errorInvalidJSONBody 23 | } 24 | 25 | xv := reflect.Indirect(reflect.ValueOf(x)) 26 | for i := 0; i < xv.NumField(); i++ { 27 | if xv.Field(i).IsNil() { 28 | return errorMissingField 29 | } 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func bodyMarshal(w http.ResponseWriter, x map[string]interface{}) error { 36 | resp, err := json.Marshal(x) 37 | if err != nil { 38 | w.Write([]byte(`{"success":false,"message":"Some internal error occurred"}`)) 39 | logger.Errorf("cannot marshal response: %v\n") 40 | return errorInternal 41 | } 42 | 43 | w.Write(resp) 44 | return nil 45 | } 46 | 47 | func getIp(r *http.Request) string { 48 | ip := r.RemoteAddr 49 | if r.Header.Get("X-Forwarded-For") != "" { 50 | ip = r.Header.Get("X-Forwarded-For") 51 | } 52 | 53 | return ip 54 | } 55 | 56 | func getUserAgent(r *http.Request) string { 57 | return r.Header.Get("User-Agent") 58 | } 59 | -------------------------------------------------------------------------------- /api/domain_moderator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type moderator struct { 8 | Email string `json:"email"` 9 | Domain string `json:"domain"` 10 | AddDate time.Time `json:"addDate"` 11 | } 12 | 13 | func domainModeratorList(domain string) ([]moderator, error) { 14 | statement := ` 15 | SELECT email, addDate 16 | FROM moderators 17 | WHERE domain=$1; 18 | ` 19 | rows, err := db.Query(statement, domain) 20 | if err != nil { 21 | logger.Errorf("cannot get moderators: %v", err) 22 | return nil, errorInternal 23 | } 24 | defer rows.Close() 25 | 26 | moderators := []moderator{} 27 | for rows.Next() { 28 | m := moderator{} 29 | if err = rows.Scan(&m.Email, &m.AddDate); err != nil { 30 | logger.Errorf("cannot Scan moderator: %v", err) 31 | return nil, errorInternal 32 | } 33 | 34 | moderators = append(moderators, m) 35 | } 36 | 37 | return moderators, nil 38 | } 39 | 40 | func isDomainModerator(domain string, email string) (bool, error) { 41 | statement := ` 42 | SELECT EXISTS ( 43 | SELECT 1 44 | FROM moderators 45 | WHERE domain=$1 AND email=$2 46 | ); 47 | ` 48 | row := db.QueryRow(statement, domain, email) 49 | 50 | var exists bool 51 | if err := row.Scan(&exists); err != nil { 52 | logger.Errorf("cannot query if moderator: %v", err) 53 | return false, errorInternal 54 | } 55 | 56 | return exists, nil 57 | } 58 | -------------------------------------------------------------------------------- /api/domain_moderator_delete_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainModeratorDeleteBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | domainModeratorNew("example.com", "test@example.com") 11 | domainModeratorNew("example.com", "test2@example.com") 12 | 13 | if err := domainModeratorDelete("example.com", "test@example.com"); err != nil { 14 | t.Errorf("unexpected error creating new domain moderator: %v", err) 15 | return 16 | } 17 | 18 | isMod, _ := isDomainModerator("example.com", "test@example.com") 19 | if isMod { 20 | t.Errorf("email %s still moderator after deletion", "test@example.com") 21 | return 22 | } 23 | 24 | isMod, _ = isDomainModerator("example.com", "test2@example.com") 25 | if !isMod { 26 | t.Errorf("email %s no longer moderator after deleting a different email", "test@example.com") 27 | return 28 | } 29 | } 30 | 31 | func TestDomainModeratorDeleteEmpty(t *testing.T) { 32 | failTestOnError(t, setupTestEnv()) 33 | 34 | domainModeratorNew("example.com", "test@example.com") 35 | 36 | if err := domainModeratorDelete("example.com", ""); err == nil { 37 | t.Errorf("expected error not found when passing empty email") 38 | return 39 | } 40 | 41 | if err := domainModeratorDelete("", ""); err == nil { 42 | t.Errorf("expected error not found when passing empty everything") 43 | return 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /api/owner_login_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestOwnerLoginBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if _, err := ownerLogin("test@example.com", "hunter2"); err == nil { 11 | t.Errorf("expected error not found when logging in without creating an account") 12 | return 13 | } 14 | 15 | ownerNew("test@example.com", "Test", "hunter2") 16 | 17 | if _, err := ownerLogin("test@example.com", "hunter2"); err != nil { 18 | t.Errorf("unexpected error when logging in: %v", err) 19 | return 20 | } 21 | 22 | if _, err := ownerLogin("test@example.com", "h******"); err == nil { 23 | t.Errorf("expected error not found when given wrong password") 24 | return 25 | } 26 | 27 | if ownerToken, err := ownerLogin("test@example.com", "hunter2"); ownerToken == "" { 28 | t.Errorf("empty token on successful login: %v", err) 29 | return 30 | } 31 | } 32 | 33 | func TestOwnerLoginEmpty(t *testing.T) { 34 | failTestOnError(t, setupTestEnv()) 35 | 36 | if _, err := ownerLogin("test@example.com", ""); err == nil { 37 | t.Errorf("expected error not found when passing empty password") 38 | return 39 | } 40 | 41 | ownerNew("test@example.com", "Test", "hunter2") 42 | 43 | if _, err := ownerLogin("test@example.com", ""); err == nil { 44 | t.Errorf("expected error not found when passing empty password") 45 | return 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | devel.env 3 | 4 | # Ignoring for IDE-specific files 5 | .idea/* 6 | .dir-locals.el 7 | 8 | # We don't *need* the vendor directory because Gopkg.lock has all the 9 | # information you might need about version pinning. The vendor directory 10 | # needlessly bloats the repo size. Discuss here: 11 | # https://gitlab.com/commento/commento-ce/issues/74 12 | vendor 13 | 14 | 15 | # Created by https://www.gitignore.io/api/vim,osx 16 | # Edit at https://www.gitignore.io/?templates=vim,osx 17 | 18 | ### OSX ### 19 | # General 20 | .DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### Vim ### 47 | # Swap 48 | [._]*.s[a-v][a-z] 49 | [._]*.sw[a-p] 50 | [._]s[a-rt-v][a-z] 51 | [._]ss[a-gi-z] 52 | [._]sw[a-p] 53 | 54 | # Session 55 | Session.vim 56 | Sessionx.vim 57 | 58 | # Temporary 59 | .netrwhist 60 | *~ 61 | # Auto-generated tag files 62 | tags 63 | # Persistent undo 64 | [._]*.un~ 65 | 66 | # End of https://www.gitignore.io/api/vim,osx 67 | -------------------------------------------------------------------------------- /api/smtp_templates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/template" 7 | ) 8 | 9 | var headerTemplate *template.Template 10 | 11 | type headerPlugs struct { 12 | FromAddress string 13 | ToName string 14 | ToAddress string 15 | Subject string 16 | } 17 | 18 | var templates map[string]*template.Template 19 | 20 | func smtpTemplatesLoad() error { 21 | var err error 22 | headerTemplate, err = template.New("header").Parse(`MIME-Version: 1.0 23 | From: Commento <{{.FromAddress}}> 24 | To: {{.ToName}} <{{.ToAddress}}> 25 | Content-Type: text/plain; charset=UTF-8 26 | Subject: {{.Subject}} 27 | 28 | `) 29 | if err != nil { 30 | logger.Errorf("cannot parse header template: %v", err) 31 | return errorMalformedTemplate 32 | } 33 | 34 | names := []string{ 35 | "confirm-hex", 36 | "reset-hex", 37 | "domain-export", 38 | "domain-export-error", 39 | } 40 | 41 | templates = make(map[string]*template.Template) 42 | 43 | logger.Infof("loading templates: %v", names) 44 | for _, name := range names { 45 | var err error 46 | templates[name] = template.New(name) 47 | templates[name], err = template.ParseFiles(fmt.Sprintf("%s/templates/%s.txt", os.Getenv("STATIC"), name)) 48 | if err != nil { 49 | logger.Errorf("cannot parse %s/templates/%s.txt: %v", os.Getenv("STATIC"), name, err) 50 | return errorMalformedTemplate 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /api/domain.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type domain struct { 8 | Domain string `json:"domain"` 9 | OwnerHex string `json:"ownerHex"` 10 | Name string `json:"name"` 11 | CreationDate time.Time `json:"creationDate"` 12 | State string `json:"state"` 13 | ImportedComments bool `json:"importedComments"` 14 | AutoSpamFilter bool `json:"autoSpamFilter"` 15 | RequireModeration bool `json:"requireModeration"` 16 | RequireIdentification bool `json:"requireIdentification"` 17 | ModerateAllAnonymous bool `json:"moderateAllAnonymous"` 18 | Moderators []moderator `json:"moderators"` 19 | EmailNotificationPolicy string `json:"emailNotificationPolicy"` 20 | CommentoProvider bool `json:"commentoProvider"` 21 | GoogleProvider bool `json:"googleProvider"` 22 | TwitterProvider bool `json:"twitterProvider"` 23 | GithubProvider bool `json:"githubProvider"` 24 | GitlabProvider bool `json:"gitlabProvider"` 25 | SsoProvider bool `json:"ssoProvider"` 26 | SsoSecret string `json:"ssoSecret"` 27 | SsoUrl string `json:"ssoUrl"` 28 | DefaultSortPolicy string `json:"defaultSortPolicy"` 29 | } 30 | -------------------------------------------------------------------------------- /frontend/js/dashboard-general.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | // Opens the general settings window. 7 | global.generalOpen = function() { 8 | $(".view").hide(); 9 | $("#general-view").show(); 10 | }; 11 | 12 | global.generalSaveHandler = function() { 13 | var data = global.dashboard.$data; 14 | 15 | global.buttonDisable("#save-general-button"); 16 | global.domainUpdate(data.domains[data.cd], function() { 17 | global.globalOKShow("Settings saved!"); 18 | global.buttonEnable("#save-general-button"); 19 | }); 20 | }; 21 | 22 | global.ssoProviderChangeHandler = function() { 23 | var data = global.dashboard.$data; 24 | 25 | if (data.domains[data.cd].ssoSecret === "") { 26 | var json = { 27 | "ownerToken": global.cookieGet("commentoOwnerToken"), 28 | "domain": data.domains[data.cd].domain, 29 | }; 30 | 31 | global.post(global.origin + "/api/domain/sso/new", json, function(resp) { 32 | if (!resp.success) { 33 | global.globalErrorShow(resp.message); 34 | return; 35 | } 36 | 37 | data.domains[data.cd].ssoSecret = resp.ssoSecret; 38 | $("#sso-secret").val(data.domains[data.cd].ssoSecret); 39 | }); 40 | } else { 41 | $("#sso-secret").val(data.domains[data.cd].ssoSecret); 42 | } 43 | }; 44 | 45 | } (window.commento, document)); 46 | -------------------------------------------------------------------------------- /frontend/sass/email-main.scss: -------------------------------------------------------------------------------- 1 | @import "colors-main.scss"; 2 | @import "common-main.scss"; 3 | 4 | .commento-email-container { 5 | display: flex; 6 | justify-content: center; 7 | width: 100%; 8 | margin: 8px 0px; 9 | 10 | .commento-email { 11 | @extend .shadow; 12 | border-radius: 4px; 13 | background: $white; 14 | width: 100%; 15 | max-width: 400px; 16 | 17 | .commento-input { 18 | display: inline; 19 | height: 40px; 20 | background: $white; 21 | border: none; 22 | outline: none; 23 | padding: 5px; 24 | padding-left: 10px; 25 | width: calc(100% - 150px); 26 | } 27 | 28 | .commento-input::placeholder { 29 | color: $gray-5; 30 | } 31 | 32 | .commento-email-button { 33 | height: 40px; 34 | min-width: 110px; 35 | float: right; 36 | background: $white; 37 | border: none; 38 | outline: none; 39 | padding: 0px 10px 0px 10px; 40 | border-left: 1px solid $gray-1; 41 | font-size: 12px; 42 | text-transform: uppercase; 43 | text-align: center; 44 | font-weight: bold; 45 | color: $blue-7; 46 | cursor: pointer; 47 | transition: all 0.2s; 48 | width: unset; 49 | } 50 | 51 | .commento-email-button:hover { 52 | color: $blue-6; 53 | } 54 | 55 | .commento-email-button:disabled { 56 | cursor: default; 57 | color: $gray-6; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /api/owner_confirm_hex_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestOwnerConfirmHexBasics(t *testing.T) { 9 | failTestOnError(t, setupTestEnv()) 10 | 11 | ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2") 12 | 13 | statement := ` 14 | UPDATE owners 15 | SET confirmedEmail=false; 16 | ` 17 | _, err := db.Exec(statement) 18 | if err != nil { 19 | t.Errorf("unexpected error when setting confirmedEmail=false: %v", err) 20 | return 21 | } 22 | 23 | confirmHex, _ := randomHex(32) 24 | 25 | statement = ` 26 | INSERT INTO 27 | ownerConfirmHexes (confirmHex, ownerHex, sendDate) 28 | VALUES ($1, $2, $3 ); 29 | ` 30 | _, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC()) 31 | if err != nil { 32 | t.Errorf("unexpected error creating inserting confirmHex: %v\n", err) 33 | return 34 | } 35 | 36 | if err = ownerConfirmHex(confirmHex); err != nil { 37 | t.Errorf("unexpected error confirming hex: %v", err) 38 | return 39 | } 40 | 41 | statement = ` 42 | SELECT confirmedEmail 43 | FROM owners 44 | WHERE ownerHex=$1; 45 | ` 46 | row := db.QueryRow(statement, ownerHex) 47 | 48 | var confirmedHex bool 49 | if err = row.Scan(&confirmedHex); err != nil { 50 | t.Errorf("unexpected error scanning confirmedEmail: %v", err) 51 | return 52 | } 53 | 54 | if !confirmedHex { 55 | t.Errorf("confirmedHex expected to be true after confirmation; found to be false") 56 | return 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /frontend/js/signup.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict" 3 | 4 | // Signs up the user and redirects to either the login page or the email 5 | // confirmation, depending on whether or not SMTP is configured in the 6 | // backend. 7 | global.signup = function(event) { 8 | event.preventDefault(); 9 | 10 | if ($("#password").val() !== $("#password2").val()) { 11 | global.textSet("#err", "The two passwords don't match"); 12 | return; 13 | } 14 | 15 | var allOk = global.unfilledMark(["#email", "#name", "#password", "#password2"], function(el) { 16 | el.css("border-bottom", "1px solid red"); 17 | }); 18 | 19 | if (!allOk) { 20 | global.textSet("#err", "Please make sure all fields are filled"); 21 | return; 22 | } 23 | 24 | var json = { 25 | "email": $("#email").val(), 26 | "name": $("#name").val(), 27 | "password": $("#password").val(), 28 | }; 29 | 30 | global.buttonDisable("#signup-button"); 31 | global.post(global.origin + "/api/owner/new", json, function(resp) { 32 | global.buttonEnable("#signup-button") 33 | 34 | if (!resp.success) { 35 | global.textSet("#err", resp.message); 36 | return; 37 | } 38 | 39 | if (resp.confirmEmail) { 40 | document.location = global.origin + "/confirm-email"; 41 | } else { 42 | document.location = global.origin + "/login?signedUp=true"; 43 | } 44 | }); 45 | }; 46 | 47 | } (window.commento, document)); 48 | -------------------------------------------------------------------------------- /api/oauth_twitter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gomodule/oauth1/oauth" 5 | "os" 6 | "sync" 7 | ) 8 | 9 | type twitterOauthState struct { 10 | CommenterToken string 11 | Cred *oauth.Credentials 12 | } 13 | 14 | var twitterClient *oauth.Client 15 | var twitterCredMapLock sync.RWMutex 16 | var twitterCredMap map[string]twitterOauthState 17 | 18 | func twitterOauthConfigure() error { 19 | twitterClient = nil 20 | if os.Getenv("TWITTER_KEY") == "" && os.Getenv("TWITTER_SECRET") == "" { 21 | return nil 22 | } 23 | 24 | if os.Getenv("TWITTER_KEY") == "" { 25 | logger.Errorf("COMMENTO_TWITTER_KEY not configured, but COMMENTO_TWITTER_SECRET is set") 26 | return errorOauthMisconfigured 27 | } 28 | 29 | if os.Getenv("TWITTER_SECRET") == "" { 30 | logger.Errorf("COMMENTO_TWITTER_SECRET not configured, but COMMENTO_TWITTER_KEY is set") 31 | return errorOauthMisconfigured 32 | } 33 | 34 | logger.Infof("loading twitter OAuth config") 35 | 36 | twitterClient = &oauth.Client{ 37 | TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token", 38 | ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authenticate", 39 | TokenRequestURI: "https://api.twitter.com/oauth/access_token", 40 | Credentials: oauth.Credentials{ 41 | Token: os.Getenv("TWITTER_KEY"), 42 | Secret: os.Getenv("TWITTER_SECRET"), 43 | }, 44 | } 45 | 46 | twitterCredMap = make(map[string]twitterOauthState, 1e3) 47 | 48 | twitterConfigured = true 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /frontend/js/dashboard-danger.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | // Opens the danger zone. 5 | global.dangerOpen = function() { 6 | $(".view").hide(); 7 | $("#danger-view").show(); 8 | }; 9 | 10 | 11 | // Deletes a domain. 12 | global.domainDeleteHandler = function() { 13 | var data = global.dashboard.$data; 14 | 15 | global.domainDelete(data.domains[data.cd].domain, function(success) { 16 | if (success) { 17 | document.location = global.origin + "/dashboard"; 18 | } 19 | }); 20 | } 21 | 22 | 23 | // Clears all comments in a domain. 24 | global.domainClearHandler = function() { 25 | var data = global.dashboard.$data; 26 | 27 | global.domainClear(data.domains[data.cd].domain, function(success) { 28 | if (success) { 29 | document.location = global.origin + "/dashboard"; 30 | } 31 | }); 32 | } 33 | 34 | 35 | // Freezes a domain. 36 | global.domainFreezeHandler = function() { 37 | var data = global.dashboard.$data; 38 | 39 | data.domains[data.cd].state = "frozen" 40 | global.domainUpdate(data.domains[data.cd]) 41 | document.location.hash = "#modal-close"; 42 | } 43 | 44 | 45 | // Unfreezes a domain. 46 | global.domainUnfreezeHandler = function() { 47 | var data = global.dashboard.$data; 48 | 49 | data.domains[data.cd].state = "unfrozen" 50 | global.domainUpdate(data.domains[data.cd]) 51 | document.location.hash = "#modal-close"; 52 | } 53 | 54 | 55 | } (window.commento, document)); 56 | -------------------------------------------------------------------------------- /api/commenter_photo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image/jpeg" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/disintegration/imaging" 11 | ) 12 | 13 | func commenterPhotoHandler(w http.ResponseWriter, r *http.Request) { 14 | c, err := commenterGetByHex(r.FormValue("commenterHex")) 15 | if err != nil { 16 | http.NotFound(w, r) 17 | return 18 | } 19 | 20 | url := c.Photo 21 | if c.Provider == "google" { 22 | if strings.HasSuffix(url, "photo.jpg") { 23 | url += "?sz=38" 24 | } else { 25 | url += "=s38" 26 | } 27 | } else if c.Provider == "github" { 28 | url += "&s=38" 29 | } else if c.Provider == "twitter" { 30 | // url += "?size=normal" 31 | } else if c.Provider == "gitlab" { 32 | url += "?width=38" 33 | } 34 | 35 | resp, err := http.Get(url) 36 | if err != nil { 37 | http.NotFound(w, r) 38 | return 39 | } 40 | defer resp.Body.Close() 41 | 42 | if c.Provider != "commento" { // Custom URL avatars need to be resized. 43 | io.Copy(w, resp.Body) 44 | return 45 | } 46 | 47 | // Limit the size of the response to 128 KiB to prevent DoS attacks 48 | // that exhaust memory. 49 | limitedResp := &io.LimitedReader{R: resp.Body, N: 128 * 1024} 50 | 51 | img, err := jpeg.Decode(limitedResp) 52 | if err != nil { 53 | fmt.Fprintf(w, "JPEG decode failed: %v\n", err) 54 | return 55 | } 56 | 57 | if err = imaging.Encode(w, imaging.Resize(img, 38, 0, imaging.Lanczos), imaging.JPEG); err != nil { 58 | fmt.Fprintf(w, "image encoding failed: %v\n", err) 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /api/oauth_google_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func resetGoogleVars() { 9 | for _, env := range []string{"GOOGLE_KEY", "GOOGLE_SECRET"} { 10 | os.Setenv(env, "") 11 | } 12 | } 13 | 14 | func TestGoogleOauthConfigureBasics(t *testing.T) { 15 | resetGoogleVars() 16 | 17 | os.Setenv("GOOGLE_KEY", "google-key") 18 | os.Setenv("GOOGLE_SECRET", "google-secret") 19 | 20 | if err := googleOauthConfigure(); err != nil { 21 | t.Errorf("unexpected error configuring google oauth: %v", err) 22 | return 23 | } 24 | 25 | if googleConfig == nil { 26 | t.Errorf("expected googleConfig!=nil got googleConfig=nil") 27 | return 28 | } 29 | } 30 | 31 | func TestGoogleOauthConfigureEmpty(t *testing.T) { 32 | resetGoogleVars() 33 | 34 | os.Setenv("GOOGLE_KEY", "google-key") 35 | 36 | if err := googleOauthConfigure(); err == nil { 37 | t.Errorf("expected error not found when configuring google oauth with empty COMMENTO_GOOGLE_SECRET") 38 | return 39 | } 40 | 41 | if googleConfig != nil { 42 | t.Errorf("expected googleConfig=nil got googleConfig=%v", googleConfig) 43 | return 44 | } 45 | } 46 | 47 | func TestGoogleOauthConfigureEmpty2(t *testing.T) { 48 | resetGoogleVars() 49 | 50 | if err := googleOauthConfigure(); err != nil { 51 | t.Errorf("unexpected error configuring google oauth with empty everything: should be disabled") 52 | return 53 | } 54 | 55 | if googleConfig != nil { 56 | t.Errorf("expected googleConfig=nil got googleConfig=%v", googleConfig) 57 | return 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/forgot.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Commento: Reset your Password 9 | 10 | 11 | 16 | 17 |
18 |
19 |
20 |
21 | Reset your Password 22 |
23 | 24 |
25 |
Email Address
26 | 27 |
28 | 29 |
30 |
31 | 32 | 33 |
34 | 35 | Suddenly remembered your password? Login. 36 |
37 |
38 | 39 | [[[.Footer]]] 40 | 41 | -------------------------------------------------------------------------------- /frontend/js/unsubscribe.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | var e; 7 | 8 | // Update the email records. 9 | global.emailUpdate = function() { 10 | $(".err").text(""); 11 | $(".msg").text(""); 12 | e.sendModeratorNotifications = $("#moderator").is(":checked"); 13 | e.sendReplyNotifications = $("#reply").is(":checked"); 14 | 15 | var json = { 16 | "email": e, 17 | }; 18 | 19 | global.buttonDisable("#save-button"); 20 | global.post(global.origin + "/api/email/update", json, function(resp) { 21 | global.buttonEnable("#save-button"); 22 | if (!resp.success) { 23 | $(".err").text(resp.message); 24 | return; 25 | } 26 | 27 | $(".msg").text("Successfully updated!"); 28 | }); 29 | } 30 | 31 | // Checks the unsubscribeSecretHex token to retrieve current settings. 32 | global.emailGet = function() { 33 | $(".err").text(""); 34 | $(".msg").text(""); 35 | var json = { 36 | "unsubscribeSecretHex": global.paramGet("unsubscribeSecretHex"), 37 | }; 38 | 39 | global.post(global.origin + "/api/email/get", json, function(resp) { 40 | $(".loading").hide(); 41 | if (!resp.success) { 42 | $(".err").text(resp.message); 43 | return; 44 | } 45 | 46 | e = resp.email; 47 | $("#email").text(e.email); 48 | $("#moderator").prop("checked", e.sendModeratorNotifications); 49 | $("#reply").prop("checked", e.sendReplyNotifications); 50 | $(".checkboxes").attr("style", ""); 51 | }); 52 | }; 53 | 54 | } (window.commento, document)); 55 | -------------------------------------------------------------------------------- /api/database_connect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | _ "github.com/lib/pq" 6 | "net/url" 7 | "os" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | func dbConnect(retriesLeft int) error { 13 | con := os.Getenv("POSTGRES") 14 | u, err := url.Parse(con) 15 | if err != nil { 16 | logger.Errorf("invalid postgres connection URI: %v", err) 17 | return err 18 | } 19 | u.User = url.UserPassword(u.User.Username(), "redacted") 20 | logger.Infof("opening connection to postgres: %s", u.String()) 21 | 22 | db, err = sql.Open("postgres", con) 23 | if err != nil { 24 | logger.Errorf("cannot open connection to postgres: %v", err) 25 | return err 26 | } 27 | 28 | err = db.Ping() 29 | if err != nil { 30 | if retriesLeft > 0 { 31 | logger.Errorf("cannot talk to postgres, retrying in 10 seconds (%d attempts left): %v", retriesLeft-1, err) 32 | time.Sleep(10 * time.Second) 33 | return dbConnect(retriesLeft - 1) 34 | } else { 35 | logger.Errorf("cannot talk to postgres, last attempt failed: %v", err) 36 | return err 37 | } 38 | } 39 | 40 | statement := ` 41 | CREATE TABLE IF NOT EXISTS migrations ( 42 | filename TEXT NOT NULL UNIQUE 43 | ); 44 | ` 45 | _, err = db.Exec(statement) 46 | if err != nil { 47 | logger.Errorf("cannot create migrations table: %v", err) 48 | return err 49 | } 50 | 51 | maxIdleConnections, err := strconv.Atoi(os.Getenv("MAX_IDLE_PG_CONNECTIONS")) 52 | if err != nil { 53 | logger.Warningf("cannot parse COMMENTO_MAX_IDLE_PG_CONNECTIONS: %v", err) 54 | maxIdleConnections = 50 55 | } 56 | 57 | db.SetMaxIdleConns(maxIdleConnections) 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /frontend/js/settings.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | global.vueConstruct = function(callback) { 7 | var reactiveData = { 8 | hasSource: global.owner.hasSource, 9 | lastFour: global.owner.lastFour, 10 | }; 11 | 12 | global.settings = new Vue({ 13 | el: "#settings", 14 | data: reactiveData, 15 | }); 16 | 17 | if (callback !== undefined) { 18 | callback(); 19 | } 20 | }; 21 | 22 | global.settingShow = function(setting) { 23 | $(".pane-setting").removeClass("selected"); 24 | $(".view").hide(); 25 | $("#" + setting).addClass("selected"); 26 | $("#" + setting + "-view").show(); 27 | }; 28 | 29 | global.deleteOwnerHandler = function() { 30 | if (!confirm("Are you absolutely sure you want to delete your account?")) { 31 | return; 32 | } 33 | 34 | var json = { 35 | "ownerToken": global.cookieGet("commentoOwnerToken"), 36 | } 37 | 38 | $("#delete-owner-button").prop("disabled", true); 39 | $("#delete-owner-button").text("Deleting..."); 40 | global.post(global.origin + "/api/owner/delete", json, function(resp) { 41 | if (!resp.success) { 42 | $("#delete-owner-button").prop("disabled", false); 43 | $("#delete-owner-button").text("Delete Account"); 44 | global.globalErrorShow(resp.message); 45 | $("#error-message").text(resp.message); 46 | return; 47 | } 48 | 49 | global.cookieDelete("commentoOwnerToken"); 50 | document.location = global.origin + "/login?deleted=true"; 51 | }); 52 | }; 53 | 54 | } (window.commento, document)); 55 | -------------------------------------------------------------------------------- /api/oauth_sso.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ssoPayload struct { 8 | Domain string `json:"domain"` 9 | Token string `json:"token"` 10 | Email string `json:"email"` 11 | Name string `json:"name"` 12 | Link string `json:"link"` 13 | Photo string `json:"photo"` 14 | } 15 | 16 | func ssoTokenNew(domain string, commenterToken string) (string, error) { 17 | token, err := randomHex(32) 18 | if err != nil { 19 | logger.Errorf("error generating SSO token hex: %v", err) 20 | return "", errorInternal 21 | } 22 | 23 | statement := ` 24 | INSERT INTO 25 | ssoTokens (token, domain, commenterToken, creationDate) 26 | VALUES ($1, $2, $3, $4 ); 27 | ` 28 | _, err = db.Exec(statement, token, domain, commenterToken, time.Now().UTC()) 29 | if err != nil { 30 | logger.Errorf("error inserting SSO token: %v", err) 31 | return "", errorInternal 32 | } 33 | 34 | return token, nil 35 | } 36 | 37 | func ssoTokenExtract(token string) (string, string, error) { 38 | statement := ` 39 | SELECT domain, commenterToken 40 | FROM ssoTokens 41 | WHERE token = $1; 42 | ` 43 | row := db.QueryRow(statement, token) 44 | 45 | var domain string 46 | var commenterToken string 47 | if err := row.Scan(&domain, &commenterToken); err != nil { 48 | return "", "", errorNoSuchToken 49 | } 50 | 51 | statement = ` 52 | DELETE FROM ssoTokens 53 | WHERE token = $1; 54 | ` 55 | if _, err := db.Exec(statement, token); err != nil { 56 | logger.Errorf("cannot delete SSO token after usage: %v", err) 57 | return "", "", errorInternal 58 | } 59 | 60 | return domain, commenterToken, nil 61 | } 62 | -------------------------------------------------------------------------------- /api/owner_get_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestOwnerGetByEmailBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2") 11 | 12 | o, err := ownerGetByEmail("test@example.com") 13 | if err != nil { 14 | t.Errorf("unexpected error on ownerGetByEmail: %v", err) 15 | return 16 | } 17 | 18 | if o.OwnerHex != ownerHex { 19 | t.Errorf("expected ownerHex=%s got ownerHex=%s", ownerHex, o.OwnerHex) 20 | return 21 | } 22 | } 23 | 24 | func TestOwnerGetByEmailDNE(t *testing.T) { 25 | failTestOnError(t, setupTestEnv()) 26 | 27 | if _, err := ownerGetByEmail("invalid@example.com"); err == nil { 28 | t.Errorf("expected error not found on ownerGetByEmail before creating an account") 29 | return 30 | } 31 | } 32 | 33 | func TestOwnerGetByOwnerTokenBasics(t *testing.T) { 34 | failTestOnError(t, setupTestEnv()) 35 | 36 | ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2") 37 | 38 | ownerToken, _ := ownerLogin("test@example.com", "hunter2") 39 | 40 | o, err := ownerGetByOwnerToken(ownerToken) 41 | if err != nil { 42 | t.Errorf("unexpected error on ownerGetByOwnerToken: %v", err) 43 | return 44 | } 45 | 46 | if o.OwnerHex != ownerHex { 47 | t.Errorf("expected ownerHex=%s got ownerHex=%s", ownerHex, o.OwnerHex) 48 | return 49 | } 50 | } 51 | 52 | func TestOwnerGetByOwnerTokenDNE(t *testing.T) { 53 | failTestOnError(t, setupTestEnv()) 54 | 55 | if _, err := ownerGetByOwnerToken("does-not-exist"); err == nil { 56 | t.Errorf("expected error not found on ownerGetByOwnerToken before creating an account") 57 | return 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Commento: Reset your Password 9 | 10 | 11 | 16 | 17 |
18 |
19 |
20 |
21 | Reset your Password 22 |
23 | 24 |
25 |
New Password
26 | 27 |
28 | 29 |
30 |
Confirm Password
31 | 32 |
33 | 34 |
35 |
36 | 37 |
38 |
39 |
40 | 41 | [[[.Footer]]] 42 | 43 | -------------------------------------------------------------------------------- /api/owner_confirm_hex.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | func ownerConfirmHex(confirmHex string) error { 10 | if confirmHex == "" { 11 | return errorMissingField 12 | } 13 | 14 | statement := ` 15 | UPDATE owners 16 | SET confirmedEmail=true 17 | WHERE ownerHex IN ( 18 | SELECT ownerHex FROM ownerConfirmHexes 19 | WHERE confirmHex=$1 20 | ); 21 | ` 22 | res, err := db.Exec(statement, confirmHex) 23 | if err != nil { 24 | logger.Errorf("cannot mark user's confirmedEmail as true: %v\n", err) 25 | return errorInternal 26 | } 27 | 28 | count, err := res.RowsAffected() 29 | if err != nil { 30 | logger.Errorf("cannot count rows affected: %v\n", err) 31 | return errorInternal 32 | } 33 | 34 | if count == 0 { 35 | return errorNoSuchConfirmationToken 36 | } 37 | 38 | statement = ` 39 | DELETE FROM ownerConfirmHexes 40 | WHERE confirmHex=$1; 41 | ` 42 | _, err = db.Exec(statement, confirmHex) 43 | if err != nil { 44 | logger.Warningf("cannot remove confirmation token: %v\n", err) 45 | // Don't return an error because this is not critical. 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func ownerConfirmHexHandler(w http.ResponseWriter, r *http.Request) { 52 | if confirmHex := r.FormValue("confirmHex"); confirmHex != "" { 53 | if err := ownerConfirmHex(confirmHex); err == nil { 54 | http.Redirect(w, r, fmt.Sprintf("%s/login?confirmed=true", os.Getenv("ORIGIN")), http.StatusTemporaryRedirect) 55 | return 56 | } 57 | } 58 | 59 | // TODO: include error message in the URL 60 | http.Redirect(w, r, fmt.Sprintf("%s/login?confirmed=false", os.Getenv("ORIGIN")), http.StatusTemporaryRedirect) 61 | } 62 | -------------------------------------------------------------------------------- /api/domain_moderator_delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func domainModeratorDelete(domain string, email string) error { 8 | if domain == "" || email == "" { 9 | return errorMissingConfig 10 | } 11 | 12 | statement := ` 13 | DELETE FROM moderators 14 | WHERE domain=$1 AND email=$2; 15 | ` 16 | _, err := db.Exec(statement, domain, email) 17 | if err != nil { 18 | logger.Errorf("cannot delete moderator: %v", err) 19 | return errorInternal 20 | } 21 | 22 | return nil 23 | } 24 | 25 | func domainModeratorDeleteHandler(w http.ResponseWriter, r *http.Request) { 26 | type request struct { 27 | OwnerToken *string `json:"ownerToken"` 28 | Domain *string `json:"domain"` 29 | Email *string `json:"email"` 30 | } 31 | 32 | var x request 33 | if err := bodyUnmarshal(r, &x); err != nil { 34 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 35 | return 36 | } 37 | 38 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 39 | if err != nil { 40 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 41 | return 42 | } 43 | 44 | domain := domainStrip(*x.Domain) 45 | authorised, err := domainOwnershipVerify(o.OwnerHex, domain) 46 | if err != nil { 47 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 48 | return 49 | } 50 | 51 | if !authorised { 52 | bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()}) 53 | return 54 | } 55 | 56 | if err = domainModeratorDelete(domain, *x.Email); err != nil { 57 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 58 | return 59 | } 60 | 61 | bodyMarshal(w, response{"success": true}) 62 | } 63 | -------------------------------------------------------------------------------- /api/comment_count.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/lib/pq" 5 | "net/http" 6 | ) 7 | 8 | func commentCount(domain string, paths []string) (map[string]int, error) { 9 | commentCounts := map[string]int{} 10 | 11 | if domain == "" { 12 | return nil, errorMissingField 13 | } 14 | 15 | if len(paths) == 0 { 16 | return nil, errorEmptyPaths 17 | } 18 | 19 | statement := ` 20 | SELECT path, commentCount 21 | FROM pages 22 | WHERE domain = $1 AND path = ANY($2); 23 | ` 24 | rows, err := db.Query(statement, domain, pq.Array(paths)) 25 | if err != nil { 26 | logger.Errorf("cannot get comments: %v", err) 27 | return nil, errorInternal 28 | } 29 | defer rows.Close() 30 | 31 | for rows.Next() { 32 | var path string 33 | var commentCount int 34 | if err = rows.Scan(&path, &commentCount); err != nil { 35 | logger.Errorf("cannot scan path and commentCount: %v", err) 36 | return nil, errorInternal 37 | } 38 | 39 | commentCounts[path] = commentCount 40 | } 41 | 42 | return commentCounts, nil 43 | } 44 | 45 | func commentCountHandler(w http.ResponseWriter, r *http.Request) { 46 | type request struct { 47 | Domain *string `json:"domain"` 48 | Paths *[]string `json:"paths"` 49 | } 50 | 51 | var x request 52 | if err := bodyUnmarshal(r, &x); err != nil { 53 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 54 | return 55 | } 56 | 57 | domain := domainStrip(*x.Domain) 58 | 59 | commentCounts, err := commentCount(domain, *x.Paths) 60 | if err != nil { 61 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 62 | return 63 | } 64 | 65 | bodyMarshal(w, response{"success": true, "commentCounts": commentCounts}) 66 | } 67 | -------------------------------------------------------------------------------- /api/utils_sanitise.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var prePlusMatch = regexp.MustCompile(`([^@\+]*)\+?(.*)@.*`) 9 | var periodsMatch = regexp.MustCompile(`[\.]`) 10 | var postAtMatch = regexp.MustCompile(`[^@]*(@.*)`) 11 | 12 | func emailStrip(email string) string { 13 | postAt := postAtMatch.ReplaceAllString(email, `$1`) 14 | prePlus := prePlusMatch.ReplaceAllString(email, `$1`) 15 | strippedEmail := periodsMatch.ReplaceAllString(prePlus, ``) + postAt 16 | 17 | return strippedEmail 18 | } 19 | 20 | var https = regexp.MustCompile(`(https?://)`) 21 | var domainTrail = regexp.MustCompile(`(/.*$)`) 22 | 23 | func domainStrip(domain string) string { 24 | noProtocol := https.ReplaceAllString(domain, ``) 25 | noTrail := domainTrail.ReplaceAllString(noProtocol, ``) 26 | 27 | return noTrail 28 | } 29 | 30 | var pathMatch = regexp.MustCompile(`(https?://[^/]*)`) 31 | 32 | func pathStrip(url string) string { 33 | strippedPath := pathMatch.ReplaceAllString(url, ``) 34 | 35 | return strippedPath 36 | } 37 | 38 | var httpsUrl = regexp.MustCompile(`^https?://`) 39 | 40 | func isHttpsUrl(in string) bool { 41 | // Admittedly, this isn't the greatest URL checker. But it does what we need. 42 | // I don't care if the user gives an invalid URL, I just want to make sure 43 | // they don't do any XSS shenanigans. Hopefully, enforcing a https?:// prefix 44 | // solves this. If this function returns false, prefix with "http://" 45 | return len(httpsUrl.FindAllString(in, -1)) != 0 46 | } 47 | 48 | func addHttpIfAbsent(in string) string { 49 | if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") { 50 | return "http://" + in 51 | } 52 | 53 | return in 54 | } 55 | -------------------------------------------------------------------------------- /api/domain_moderator_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainModeratorListBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | domainModeratorNew("example.com", "test@example.com") 11 | domainModeratorNew("example.com", "test2@example.com") 12 | 13 | mods, err := domainModeratorList("example.com") 14 | if err != nil { 15 | t.Errorf("unexpected error listing domain moderators: %v", err) 16 | return 17 | } 18 | 19 | if len(mods) != 2 { 20 | t.Errorf("expected number of domain moderators to be 2 got %d", len(mods)) 21 | return 22 | } 23 | 24 | if mods[0].Email != "test@example.com" { 25 | t.Errorf("expected first domain to be test@example.com got %s", mods[0].Email) 26 | return 27 | } 28 | 29 | if mods[1].Email != "test2@example.com" { 30 | t.Errorf("expected first domain to be test2@example.com got %s", mods[0].Email) 31 | return 32 | } 33 | } 34 | 35 | func TestIsDomainModeratorBasics(t *testing.T) { 36 | failTestOnError(t, setupTestEnv()) 37 | 38 | domainModeratorNew("example.com", "test@example.com") 39 | 40 | isMod, err := isDomainModerator("example.com", "test@example.com") 41 | if err != nil { 42 | t.Errorf("unexpected error checking if email is a moderator: %v", err) 43 | return 44 | } 45 | 46 | if !isMod { 47 | t.Errorf("expected test@example.com to be a moderator got isMod=false") 48 | return 49 | } 50 | 51 | isMod, err = isDomainModerator("example.com", "test2@example.com") 52 | if err != nil { 53 | t.Errorf("unexpected error checking if email is a moderator: %v", err) 54 | return 55 | } 56 | 57 | if isMod { 58 | t.Errorf("expected test2@example.com to not be a moderator got isMod=true") 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/sass/tomorrow.scss: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Light by Daniel Gamage 4 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax 5 | 6 | base: #fafafa 7 | mono-1: #383a42 8 | mono-2: #686b77 9 | mono-3: #a0a1a7 10 | hue-1: #0184bb 11 | hue-2: #4078f2 12 | hue-3: #a626a4 13 | hue-4: #50a14f 14 | hue-5: #e45649 15 | hue-5-2: #c91243 16 | hue-6: #986801 17 | hue-6-2: #c18401 18 | 19 | */ 20 | 21 | .hljs { 22 | display: block; 23 | overflow-x: auto; 24 | padding: 1em; 25 | color: #383a42; 26 | background: #ffffff; 27 | border-radius: 3px; 28 | } 29 | 30 | .hljs-comment, 31 | .hljs-quote { 32 | color: #a0a1a7; 33 | font-style: italic; 34 | } 35 | 36 | .hljs-doctag, 37 | .hljs-keyword, 38 | .hljs-formula { 39 | color: #a626a4; 40 | } 41 | 42 | .hljs-section, 43 | .hljs-name, 44 | .hljs-selector-tag, 45 | .hljs-deletion, 46 | .hljs-subst { 47 | color: #e45649; 48 | } 49 | 50 | .hljs-literal { 51 | color: #0184bb; 52 | } 53 | 54 | .hljs-string, 55 | .hljs-regexp, 56 | .hljs-addition, 57 | .hljs-attribute, 58 | .hljs-meta-string { 59 | color: #50a14f; 60 | } 61 | 62 | .hljs-built_in, 63 | .hljs-class .hljs-title { 64 | color: #c18401; 65 | } 66 | 67 | .hljs-attr, 68 | .hljs-variable, 69 | .hljs-template-variable, 70 | .hljs-type, 71 | .hljs-selector-class, 72 | .hljs-selector-attr, 73 | .hljs-selector-pseudo, 74 | .hljs-number { 75 | color: #986801; 76 | } 77 | 78 | .hljs-symbol, 79 | .hljs-bullet, 80 | .hljs-link, 81 | .hljs-meta, 82 | .hljs-selector-id, 83 | .hljs-title { 84 | color: #4078f2; 85 | } 86 | 87 | .hljs-emphasis { 88 | font-style: italic; 89 | } 90 | 91 | .hljs-strong { 92 | font-weight: bold; 93 | } 94 | 95 | .hljs-link { 96 | text-decoration: underline; 97 | } 98 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # backend build (api server) 2 | FROM golang:1.14-alpine AS api-build 3 | RUN apk add --no-cache --update bash dep make git curl g++ 4 | 5 | COPY ./api /go/src/commento/api/ 6 | WORKDIR /go/src/commento/api 7 | RUN make prod -j$(($(nproc) + 1)) 8 | 9 | 10 | # frontend build (html, js, css, images) 11 | FROM node:10-alpine AS frontend-build 12 | RUN apk add --no-cache --update bash make python2 g++ 13 | 14 | COPY ./frontend /commento/frontend 15 | WORKDIR /commento/frontend/ 16 | RUN make prod -j$(($(nproc) + 1)) 17 | 18 | 19 | # templates and db build 20 | FROM alpine:3.9 AS templates-db-build 21 | RUN apk add --no-cache --update bash make 22 | 23 | COPY ./templates /commento/templates 24 | WORKDIR /commento/templates 25 | RUN make prod -j$(($(nproc) + 1)) 26 | 27 | COPY ./db /commento/db 28 | WORKDIR /commento/db 29 | RUN make prod -j$(($(nproc) + 1)) 30 | 31 | 32 | # final image 33 | FROM alpine:3.7 34 | RUN apk add --no-cache --update ca-certificates 35 | 36 | COPY --from=api-build /go/src/commento/api/build/prod/commento /commento/commento 37 | COPY --from=frontend-build /commento/frontend/build/prod/js /commento/js 38 | COPY --from=frontend-build /commento/frontend/build/prod/css /commento/css 39 | COPY --from=frontend-build /commento/frontend/build/prod/images /commento/images 40 | COPY --from=frontend-build /commento/frontend/build/prod/fonts /commento/fonts 41 | COPY --from=frontend-build /commento/frontend/build/prod/*.html /commento/ 42 | COPY --from=templates-db-build /commento/templates/build/prod/templates /commento/templates/ 43 | COPY --from=templates-db-build /commento/db/build/prod/db /commento/db/ 44 | 45 | EXPOSE 8080 46 | WORKDIR /commento/ 47 | ENV COMMENTO_BIND_ADDRESS="0.0.0.0" 48 | ENTRYPOINT ["/commento/commento"] 49 | -------------------------------------------------------------------------------- /api/domain_new.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | func domainNew(ownerHex string, name string, domain string) error { 10 | if ownerHex == "" || name == "" || domain == "" { 11 | return errorMissingField 12 | } 13 | 14 | if strings.Contains(domain, "/") { 15 | return errorInvalidDomain 16 | } 17 | 18 | statement := ` 19 | INSERT INTO 20 | domains (ownerHex, name, domain, creationDate) 21 | VALUES ($1, $2, $3, $4 ); 22 | ` 23 | _, err := db.Exec(statement, ownerHex, name, domain, time.Now().UTC()) 24 | if err != nil { 25 | // TODO: Make sure this is really the error. 26 | return errorDomainAlreadyExists 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func domainNewHandler(w http.ResponseWriter, r *http.Request) { 33 | type request struct { 34 | OwnerToken *string `json:"ownerToken"` 35 | Name *string `json:"name"` 36 | Domain *string `json:"domain"` 37 | } 38 | 39 | var x request 40 | if err := bodyUnmarshal(r, &x); err != nil { 41 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 42 | return 43 | } 44 | 45 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 46 | if err != nil { 47 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 48 | return 49 | } 50 | 51 | domain := domainStrip(*x.Domain) 52 | 53 | if err = domainNew(o.OwnerHex, *x.Name, domain); err != nil { 54 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 55 | return 56 | } 57 | 58 | if err = domainModeratorNew(domain, o.Email); err != nil { 59 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 60 | return 61 | } 62 | 63 | bodyMarshal(w, response{"success": true, "domain": domain}) 64 | } 65 | -------------------------------------------------------------------------------- /api/comment_count_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestCommentCountBasics(t *testing.T) { 9 | failTestOnError(t, setupTestEnv()) 10 | 11 | commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "http://example.com/photo.jpg", "google", "") 12 | 13 | commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC()) 14 | commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC()) 15 | commentNew(commenterHex, "example.com", "/path.html", "root", "**baz**", "unapproved", time.Now().UTC()) 16 | 17 | counts, err := commentCount("example.com", []string{"/path.html"}) 18 | if err != nil { 19 | t.Errorf("unexpected error counting comments: %v", err) 20 | return 21 | } 22 | 23 | if counts["/path.html"] != 3 { 24 | t.Errorf("expected count=3 got count=%d", counts["/path.html"]) 25 | return 26 | } 27 | } 28 | 29 | func TestCommentCountNewPage(t *testing.T) { 30 | failTestOnError(t, setupTestEnv()) 31 | 32 | counts, err := commentCount("example.com", []string{"/path.html"}) 33 | if err != nil { 34 | t.Errorf("unexpected error counting comments: %v", err) 35 | return 36 | } 37 | 38 | if counts["/path.html"] != 0 { 39 | t.Errorf("expected count=0 got count=%d", counts["/path.html"]) 40 | return 41 | } 42 | } 43 | 44 | func TestCommentCountEmpty(t *testing.T) { 45 | if _, err := commentCount("example.com", []string{""}); err != nil { 46 | t.Errorf("unexpected error counting comments on empty path: %v", err) 47 | return 48 | } 49 | 50 | if _, err := commentCount("", []string{""}); err == nil { 51 | t.Errorf("expected error not found counting comments with empty everything") 52 | return 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /scripts/autoserve: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | binary_name=commento 4 | 5 | trap ctrl_c INT 6 | ctrl_c() { 7 | kill -SIGTERM $(jobs -p) 8 | wait $(jobs -p) 9 | exit 10 | } 11 | 12 | if [[ "$1" == "" ]]; then 13 | version=devel 14 | else 15 | version=$1 16 | fi 17 | 18 | binary_pid= 19 | if make $version -j$(($(nproc) + 1)); then 20 | source devel.env 21 | cd build/$version 22 | ./$binary_name & 23 | binary_pid=$! 24 | cd ../../ 25 | fi 26 | 27 | find_cmd() { 28 | find . ! \( -path "*.git" -o -path "*.git/*" -o -path "*/build" -o -path "*/build/*" \) -type $1 29 | } 30 | 31 | while true; do 32 | inotifywait -q --format '' -e close_write $(find_cmd f) & 33 | write_pid=$! 34 | 35 | inotifywait -q --format '' -e create $(find_cmd d) & 36 | create_pid=$! 37 | 38 | wait -n 39 | 40 | if ps -p $write_pid >/dev/null && ps -p $create_pid >/dev/null; then 41 | # The wait finished because the build was successful, but our binary exited 42 | # prematurely. We need to back to waiting. 43 | kill -SIGTERM $write_pid $create_pid 2>/dev/null 44 | wait $write_pid $create_pid 45 | printf "\033[1;31m ** $binary_name failed to execute properly\n\033[0m" 46 | continue 47 | fi 48 | 49 | kill -SIGTERM $write_pid $create_pid 2>/dev/null 50 | wait $write_pid $create_pid 51 | 52 | # TODO: Is sending SIGKILL the best idea? Maybe our backend has some tasks 53 | # to complete before terminating gracefully? 54 | if [[ ! -z "$binary_pid" ]]; then 55 | kill -SIGINT $binary_pid 56 | wait $binary_pid 57 | fi 58 | 59 | if make $version -j$(($(nproc) + 1)); then 60 | source devel.env 61 | cd build/$version 62 | ./$binary_name & 63 | binary_pid=$! 64 | cd ../../ 65 | else 66 | binary_pid= 67 | fi 68 | done 69 | -------------------------------------------------------------------------------- /api/email_moderate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func emailModerateHandler(w http.ResponseWriter, r *http.Request) { 9 | unsubscribeSecretHex := r.FormValue("unsubscribeSecretHex") 10 | e, err := emailGetByUnsubscribeSecretHex(unsubscribeSecretHex) 11 | if err != nil { 12 | fmt.Fprintf(w, "error: %v", err.Error()) 13 | return 14 | } 15 | 16 | action := r.FormValue("action") 17 | if action != "delete" && action != "approve" { 18 | fmt.Fprintf(w, "error: invalid action") 19 | return 20 | } 21 | 22 | commentHex := r.FormValue("commentHex") 23 | if commentHex == "" { 24 | fmt.Fprintf(w, "error: invalid commentHex") 25 | return 26 | } 27 | 28 | statement := ` 29 | SELECT domain, path 30 | FROM comments 31 | WHERE commentHex = $1; 32 | ` 33 | row := db.QueryRow(statement, commentHex) 34 | 35 | var domain, path string 36 | if err = row.Scan(&domain, &path); err != nil { 37 | // TODO: is this the only error? 38 | fmt.Fprintf(w, "error: no such comment found (perhaps it has been deleted?)") 39 | return 40 | } 41 | 42 | isModerator, err := isDomainModerator(domain, e.Email) 43 | if err != nil { 44 | logger.Errorf("error checking if %s is a moderator: %v", e.Email, err) 45 | fmt.Fprintf(w, "error checking if %s is a moderator: %v", e.Email, err) 46 | return 47 | } 48 | 49 | if !isModerator { 50 | fmt.Fprintf(w, "error: you're not a moderator for that domain") 51 | return 52 | } 53 | 54 | if action == "approve" { 55 | err = commentApprove(commentHex, domain + path) 56 | } else { 57 | err = commentDelete(commentHex, domain + path) 58 | } 59 | 60 | if err != nil { 61 | fmt.Fprintf(w, "error: %v", err) 62 | return 63 | } 64 | 65 | fmt.Fprintf(w, "comment successfully %sd", action) 66 | } 67 | -------------------------------------------------------------------------------- /frontend/sass/common-main.scss: -------------------------------------------------------------------------------- 1 | @import "colors-main.scss"; 2 | @import "source-sans.scss"; 3 | 4 | html, input, button, textarea { 5 | font-family: 'Source Sans Pro', sans-serif; 6 | } 7 | 8 | html { 9 | font-size: 14px; 10 | color: $gray-7; 11 | background: $gray-0; 12 | } 13 | 14 | body { 15 | margin: 0px; 16 | } 17 | 18 | a { 19 | text-decoration: none; 20 | } 21 | 22 | a:hover { 23 | cursor: pointer; 24 | } 25 | 26 | .shadow { 27 | box-shadow: 0 1px 3px rgba(50,50,93,.15), 0 1px 0 rgba(0,0,0,.02); 28 | } 29 | 30 | .footer { 31 | position: relative; 32 | bottom: 0px; 33 | width: 100%; 34 | margin-top: 72px; 35 | 36 | .copyright { 37 | align-items: none; 38 | color: $gray-3; 39 | background: $white; 40 | text-align: center; 41 | padding: 12px; 42 | } 43 | 44 | .footer-inner { 45 | width: 100%; 46 | background: $white; 47 | display: flex; 48 | justify-content: center; 49 | align-items: center; 50 | 51 | .links { 52 | display: flex; 53 | justify-content: center; 54 | width: 600px; 55 | } 56 | 57 | .link-group { 58 | margin: 40px; 59 | 60 | .header { 61 | text-transform: uppercase; 62 | font-weight: 700; 63 | font-size: 12px; 64 | color: $gray-5; 65 | } 66 | } 67 | 68 | .link { 69 | margin-top: 12px; 70 | margin-bottom: 12px; 71 | display: block; 72 | color: $gray-5; 73 | transition: all 0.2s; 74 | } 75 | 76 | .link:hover { 77 | color: $gray-7; 78 | } 79 | 80 | @media only screen and (max-width: 1000px) { 81 | .link-group { 82 | display: block 83 | } 84 | 85 | .links { 86 | display: block; 87 | width: 90%; 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /api/smtp_configure_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func smtpVarsClean() { 9 | for _, env := range []string{"SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_HOST", "SMTP_PORT", "SMTP_FROM_ADDRESS"} { 10 | os.Setenv(env, "") 11 | } 12 | } 13 | 14 | func TestSmtpConfigureBasics(t *testing.T) { 15 | failTestOnError(t, setupTestEnv()) 16 | smtpVarsClean() 17 | 18 | os.Setenv("SMTP_USERNAME", "test@example.com") 19 | os.Setenv("SMTP_PASSWORD", "hunter2") 20 | os.Setenv("SMTP_HOST", "smtp.commento.io") 21 | os.Setenv("SMTP_FROM_ADDRESS", "no-reply@commento.io") 22 | 23 | if err := smtpConfigure(); err != nil { 24 | t.Errorf("unexpected error when configuring SMTP: %v", err) 25 | return 26 | } 27 | } 28 | 29 | func TestSmtpConfigureEmptyHost(t *testing.T) { 30 | failTestOnError(t, setupTestEnv()) 31 | smtpVarsClean() 32 | 33 | os.Setenv("SMTP_USERNAME", "test@example.com") 34 | os.Setenv("SMTP_PASSWORD", "hunter2") 35 | os.Setenv("SMTP_FROM_ADDRESS", "no-reply@commento.io") 36 | 37 | if err := smtpConfigure(); err != nil { 38 | t.Errorf("unexpected error when configuring SMTP: %v", err) 39 | return 40 | } 41 | 42 | if smtpConfigured { 43 | t.Errorf("SMTP configured when it should not be due to empty COMMENTO_SMTP_HOST") 44 | return 45 | } 46 | } 47 | 48 | func TestSmtpConfigureEmptyAddress(t *testing.T) { 49 | failTestOnError(t, setupTestEnv()) 50 | smtpVarsClean() 51 | 52 | os.Setenv("SMTP_USERNAME", "test@example.com") 53 | os.Setenv("SMTP_PASSWORD", "hunter2") 54 | os.Setenv("SMTP_HOST", "smtp.commento.io") 55 | os.Setenv("SMTP_PORT", "25") 56 | 57 | if err := smtpConfigure(); err == nil { 58 | t.Errorf("expected error not found; SMTP should not be configured when COMMENTO_SMTP_FROM_ADDRESS is empty") 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /api/domain_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | var domainsRowColumns = ` 6 | domains.domain, 7 | domains.ownerHex, 8 | domains.name, 9 | domains.creationDate, 10 | domains.state, 11 | domains.importedComments, 12 | domains.autoSpamFilter, 13 | domains.requireModeration, 14 | domains.requireIdentification, 15 | domains.moderateAllAnonymous, 16 | domains.emailNotificationPolicy, 17 | domains.commentoProvider, 18 | domains.googleProvider, 19 | domains.twitterProvider, 20 | domains.githubProvider, 21 | domains.gitlabProvider, 22 | domains.ssoProvider, 23 | domains.ssoSecret, 24 | domains.ssoUrl, 25 | domains.defaultSortPolicy 26 | ` 27 | 28 | func domainsRowScan(s sqlScanner, d *domain) error { 29 | return s.Scan( 30 | &d.Domain, 31 | &d.OwnerHex, 32 | &d.Name, 33 | &d.CreationDate, 34 | &d.State, 35 | &d.ImportedComments, 36 | &d.AutoSpamFilter, 37 | &d.RequireModeration, 38 | &d.RequireIdentification, 39 | &d.ModerateAllAnonymous, 40 | &d.EmailNotificationPolicy, 41 | &d.CommentoProvider, 42 | &d.GoogleProvider, 43 | &d.TwitterProvider, 44 | &d.GithubProvider, 45 | &d.GitlabProvider, 46 | &d.SsoProvider, 47 | &d.SsoSecret, 48 | &d.SsoUrl, 49 | &d.DefaultSortPolicy, 50 | ) 51 | } 52 | 53 | func domainGet(dmn string) (domain, error) { 54 | if dmn == "" { 55 | return domain{}, errorMissingField 56 | } 57 | 58 | statement := ` 59 | SELECT ` + domainsRowColumns + ` 60 | FROM domains 61 | WHERE domain = $1; 62 | ` 63 | row := db.QueryRow(statement, dmn) 64 | 65 | var err error 66 | d := domain{} 67 | if err = domainsRowScan(row, &d); err != nil { 68 | return d, errorNoSuchDomain 69 | } 70 | 71 | d.Moderators, err = domainModeratorList(d.Domain) 72 | if err != nil { 73 | return domain{}, err 74 | } 75 | 76 | return d, nil 77 | } 78 | -------------------------------------------------------------------------------- /api/domain_sso.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func domainSsoSecretNew(domain string) (string, error) { 8 | if domain == "" { 9 | return "", errorMissingField 10 | } 11 | 12 | ssoSecret, err := randomHex(32) 13 | if err != nil { 14 | logger.Errorf("error generating SSO secret hex: %v", err) 15 | return "", errorInternal 16 | } 17 | 18 | statement := ` 19 | UPDATE domains 20 | SET ssoSecret = $2 21 | WHERE domain = $1; 22 | ` 23 | _, err = db.Exec(statement, domain, ssoSecret) 24 | if err != nil { 25 | logger.Errorf("cannot update ssoSecret: %v", err) 26 | return "", errorInternal 27 | } 28 | 29 | return ssoSecret, nil 30 | } 31 | 32 | func domainSsoSecretNewHandler(w http.ResponseWriter, r *http.Request) { 33 | type request struct { 34 | OwnerToken *string `json:"ownerToken"` 35 | Domain *string `json:"domain"` 36 | } 37 | 38 | var x request 39 | if err := bodyUnmarshal(r, &x); err != nil { 40 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 41 | return 42 | } 43 | 44 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 45 | if err != nil { 46 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 47 | return 48 | } 49 | 50 | domain := domainStrip(*x.Domain) 51 | isOwner, err := domainOwnershipVerify(o.OwnerHex, domain) 52 | if err != nil { 53 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 54 | return 55 | } 56 | 57 | if !isOwner { 58 | bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()}) 59 | return 60 | } 61 | 62 | ssoSecret, err := domainSsoSecretNew(domain) 63 | if err != nil { 64 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 65 | return 66 | } 67 | 68 | bodyMarshal(w, response{"success": true, "ssoSecret": ssoSecret}) 69 | } 70 | -------------------------------------------------------------------------------- /frontend/js/dashboard-import.js: -------------------------------------------------------------------------------- 1 | (function (global, document) { 2 | "use strict"; 3 | 4 | (document); 5 | 6 | // Opens the import window. 7 | global.importOpen = function() { 8 | $(".view").hide(); 9 | $("#import-view").show(); 10 | } 11 | 12 | global.importDisqus = function() { 13 | var url = $("#disqus-url").val(); 14 | var data = global.dashboard.$data; 15 | 16 | var json = { 17 | "ownerToken": global.cookieGet("commentoOwnerToken"), 18 | "domain": data.domains[data.cd].domain, 19 | "url": url, 20 | } 21 | 22 | global.buttonDisable("#disqus-import-button"); 23 | global.post(global.origin + "/api/domain/import/disqus", json, function(resp) { 24 | global.buttonEnable("#disqus-import-button"); 25 | 26 | if (!resp.success) { 27 | global.globalErrorShow(resp.message); 28 | return; 29 | } 30 | 31 | $("#disqus-import-button").hide(); 32 | 33 | global.globalOKShow("Imported " + resp.numImported + " comments!"); 34 | }); 35 | } 36 | 37 | global.importCommento = function() { 38 | var url = $("#commento-url").val(); 39 | var data = global.dashboard.$data; 40 | 41 | var json = { 42 | "ownerToken": global.cookieGet("commentoOwnerToken"), 43 | "domain": data.domains[data.cd].domain, 44 | "url": url, 45 | } 46 | 47 | global.buttonDisable("#commento-import-button"); 48 | global.post(global.origin + "/api/domain/import/commento", json, function(resp) { 49 | global.buttonEnable("#commento-import-button"); 50 | 51 | if (!resp.success) { 52 | global.globalErrorShow(resp.message); 53 | return; 54 | } 55 | 56 | $("#commento-import-button").hide(); 57 | 58 | global.globalOKShow("Imported " + resp.numImported + " comments!"); 59 | }); 60 | } 61 | 62 | } (window.commento, document)); 63 | -------------------------------------------------------------------------------- /api/commenter_login_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommenterLoginBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if _, err := commenterLogin("test@example.com", "hunter2"); err == nil { 11 | t.Errorf("expected error not found when logging in without creating an account") 12 | return 13 | } 14 | 15 | commenterNew("test@example.com", "Test", "undefined", "undefined", "commento", "hunter2") 16 | 17 | if _, err := commenterLogin("test@example.com", "hunter2"); err != nil { 18 | t.Errorf("unexpected error when logging in: %v", err) 19 | return 20 | } 21 | 22 | if _, err := commenterLogin("test@example.com", "h******"); err == nil { 23 | t.Errorf("expected error not found when given wrong password") 24 | return 25 | } 26 | 27 | if commenterToken, err := commenterLogin("test@example.com", "hunter2"); commenterToken == "" { 28 | t.Errorf("empty comenterToken on successful login: %v", err) 29 | return 30 | } 31 | } 32 | 33 | func TestCommenterLoginEmpty(t *testing.T) { 34 | failTestOnError(t, setupTestEnv()) 35 | 36 | if _, err := commenterLogin("test@example.com", ""); err == nil { 37 | t.Errorf("expected error not found when passing empty password") 38 | return 39 | } 40 | 41 | commenterNew("test@example.com", "Test", "undefined", "", "commenter", "hunter2") 42 | 43 | if _, err := commenterLogin("test@example.com", ""); err == nil { 44 | t.Errorf("expected error not found when passing empty password") 45 | return 46 | } 47 | } 48 | 49 | func TestCommenterLoginNonCommento(t *testing.T) { 50 | failTestOnError(t, setupTestEnv()) 51 | 52 | commenterNew("test@example.com", "Test", "undefined", "undefined", "google", "") 53 | 54 | if _, err := commenterLogin("test@example.com", "hunter2"); err == nil { 55 | t.Errorf("expected error not found logging into a non-Commento account") 56 | return 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /api/domain_moderator_new.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | func domainModeratorNew(domain string, email string) error { 9 | if domain == "" || email == "" { 10 | return errorMissingField 11 | } 12 | 13 | if err := emailNew(email); err != nil { 14 | logger.Errorf("cannot create email when creating moderator: %v", err) 15 | return errorInternal 16 | } 17 | 18 | statement := ` 19 | INSERT INTO 20 | moderators (domain, email, addDate) 21 | VALUES ($1, $2, $3 ); 22 | ` 23 | _, err := db.Exec(statement, domain, email, time.Now().UTC()) 24 | if err != nil { 25 | logger.Errorf("cannot insert new moderator: %v", err) 26 | return errorInternal 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func domainModeratorNewHandler(w http.ResponseWriter, r *http.Request) { 33 | type request struct { 34 | OwnerToken *string `json:"ownerToken"` 35 | Domain *string `json:"domain"` 36 | Email *string `json:"email"` 37 | } 38 | 39 | var x request 40 | if err := bodyUnmarshal(r, &x); err != nil { 41 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 42 | return 43 | } 44 | 45 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 46 | if err != nil { 47 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 48 | return 49 | } 50 | 51 | domain := domainStrip(*x.Domain) 52 | isOwner, err := domainOwnershipVerify(o.OwnerHex, domain) 53 | if err != nil { 54 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 55 | return 56 | } 57 | 58 | if !isOwner { 59 | bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()}) 60 | return 61 | } 62 | 63 | if err = domainModeratorNew(domain, *x.Email); err != nil { 64 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 65 | return 66 | } 67 | 68 | bodyMarshal(w, response{"success": true}) 69 | } 70 | -------------------------------------------------------------------------------- /api/domain_list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func domainList(ownerHex string) ([]domain, error) { 8 | if ownerHex == "" { 9 | return []domain{}, errorMissingField 10 | } 11 | 12 | statement := ` 13 | SELECT ` + domainsRowColumns + ` 14 | FROM domains 15 | WHERE ownerHex=$1; 16 | ` 17 | rows, err := db.Query(statement, ownerHex) 18 | if err != nil { 19 | logger.Errorf("cannot query domains: %v", err) 20 | return nil, errorInternal 21 | } 22 | defer rows.Close() 23 | 24 | domains := []domain{} 25 | for rows.Next() { 26 | var d domain 27 | if err = domainsRowScan(rows, &d); err != nil { 28 | logger.Errorf("cannot Scan domain: %v", err) 29 | return nil, errorInternal 30 | } 31 | 32 | d.Moderators, err = domainModeratorList(d.Domain) 33 | if err != nil { 34 | return []domain{}, err 35 | } 36 | 37 | domains = append(domains, d) 38 | } 39 | 40 | return domains, rows.Err() 41 | } 42 | 43 | func domainListHandler(w http.ResponseWriter, r *http.Request) { 44 | type request struct { 45 | OwnerToken *string `json:"ownerToken"` 46 | } 47 | 48 | var x request 49 | if err := bodyUnmarshal(r, &x); err != nil { 50 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 51 | return 52 | } 53 | 54 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 55 | if err != nil { 56 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 57 | return 58 | } 59 | 60 | domains, err := domainList(o.OwnerHex) 61 | if err != nil { 62 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 63 | return 64 | } 65 | 66 | bodyMarshal(w, response{ 67 | "success": true, 68 | "domains": domains, 69 | "configuredOauths": map[string]bool{ 70 | "google": googleConfigured, 71 | "twitter": twitterConfigured, 72 | "github": githubConfigured, 73 | "gitlab": gitlabConfigured, 74 | }, 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /api/owner_delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func ownerDelete(ownerHex string, deleteDomains bool) error { 8 | domains, err := domainList(ownerHex) 9 | if err != nil { 10 | return err 11 | } 12 | 13 | if len(domains) > 0 { 14 | if !deleteDomains { 15 | return errorCannotDeleteOwnerWithActiveDomains 16 | } 17 | for _, d := range domains { 18 | if err := domainDelete(d.Domain); err != nil { 19 | return err 20 | } 21 | } 22 | } 23 | 24 | statement := ` 25 | DELETE FROM owners 26 | WHERE ownerHex = $1; 27 | ` 28 | _, err = db.Exec(statement, ownerHex) 29 | if err != nil { 30 | return errorNoSuchOwner 31 | } 32 | 33 | statement = ` 34 | DELETE FROM ownersessions 35 | WHERE ownerHex = $1; 36 | ` 37 | _, err = db.Exec(statement, ownerHex) 38 | if err != nil { 39 | logger.Errorf("cannot delete from ownersessions: %v", err) 40 | return errorInternal 41 | } 42 | 43 | statement = ` 44 | DELETE FROM resethexes 45 | WHERE hex = $1; 46 | ` 47 | _, err = db.Exec(statement, ownerHex) 48 | if err != nil { 49 | logger.Errorf("cannot delete from resethexes: %v", err) 50 | return errorInternal 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func ownerDeleteHandler(w http.ResponseWriter, r *http.Request) { 57 | type request struct { 58 | OwnerToken *string `json:"ownerToken"` 59 | } 60 | 61 | var x request 62 | if err := bodyUnmarshal(r, &x); err != nil { 63 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 64 | return 65 | } 66 | 67 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 68 | if err != nil { 69 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 70 | return 71 | } 72 | 73 | if err = ownerDelete(o.OwnerHex, false); err != nil { 74 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 75 | return 76 | } 77 | 78 | bodyMarshal(w, response{"success": true}) 79 | } 80 | -------------------------------------------------------------------------------- /db/20190213033530-email-notifications.sql: -------------------------------------------------------------------------------- 1 | -- Email notifications 2 | -- There are two kinds of email notifications: those sent to domain moderators 3 | -- and those sent to commenters. Domain owners can choose to subscribe their 4 | -- moderators to all comments, those pending moderation, or no emails. Each 5 | -- moderator can independently opt out of these emails, of course. Commenters, 6 | -- on the other, can choose to opt into reply notifications by email. 7 | 8 | -- TODO: daily and weekly digests instead of just batched real-time emails? 9 | 10 | -- TODO: more granular options to unsubscribe from emails for particular 11 | -- domains can be provided - add unsubscribedReplyDomains []TEXT and 12 | -- unsubscribedModeratorDomains []TEXT to emails table? 13 | 14 | -- Each address has a cooldown period so that emails aren't sent within 10 15 | -- minutes of each other. Why is this a separate table instead of another 16 | -- column on commenters/owners? Because there may be some mods that haven't 17 | -- logged in to create a row in the commenter table. 18 | CREATE TABLE IF NOT EXISTS emails ( 19 | email TEXT NOT NULL UNIQUE PRIMARY KEY, 20 | unsubscribeSecretHex TEXT NOT NULL UNIQUE, 21 | lastEmailNotificationDate TIMESTAMP NOT NULL, 22 | pendingEmails INTEGER NOT NULL DEFAULT 0, 23 | sendReplyNotifications BOOLEAN NOT NULL DEFAULT false, 24 | sendModeratorNotifications BOOLEAN NOT NULL DEFAULT true 25 | ); 26 | 27 | CREATE INDEX IF NOT EXISTS unsubscribeSecretHexIndex ON emails(unsubscribeSecretHex); 28 | 29 | -- Which comments should be sent? 30 | -- Possible values: all, pending-moderation, none 31 | -- Default to pending-moderation because this is critical. If the user forgets 32 | -- to moderate, some comments will never see the light of day. 33 | ALTER TABLE domains 34 | ADD COLUMN emailNotificationPolicy TEXT DEFAULT 'pending-moderation'; 35 | 36 | -- Each page now needs to store the title of the page. 37 | ALTER TABLE pages 38 | ADD COLUMN title TEXT DEFAULT ''; 39 | -------------------------------------------------------------------------------- /api/email_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | var emailsRowColumns = ` 8 | emails.email, 9 | emails.unsubscribeSecretHex, 10 | emails.lastEmailNotificationDate, 11 | emails.sendReplyNotifications, 12 | emails.sendModeratorNotifications 13 | ` 14 | 15 | func emailsRowScan(s sqlScanner, e *email) error { 16 | return s.Scan( 17 | &e.Email, 18 | &e.UnsubscribeSecretHex, 19 | &e.LastEmailNotificationDate, 20 | &e.SendReplyNotifications, 21 | &e.SendModeratorNotifications, 22 | ) 23 | } 24 | 25 | func emailGet(em string) (email, error) { 26 | statement := ` 27 | SELECT ` + emailsRowColumns + ` 28 | FROM emails 29 | WHERE email = $1; 30 | ` 31 | row := db.QueryRow(statement, em) 32 | 33 | var e email 34 | if err := emailsRowScan(row, &e); err != nil { 35 | // TODO: is this the only error? 36 | return e, errorNoSuchEmail 37 | } 38 | 39 | return e, nil 40 | } 41 | 42 | func emailGetByUnsubscribeSecretHex(unsubscribeSecretHex string) (email, error) { 43 | statement := ` 44 | SELECT ` + emailsRowColumns + ` 45 | FROM emails 46 | WHERE unsubscribeSecretHex = $1; 47 | ` 48 | row := db.QueryRow(statement, unsubscribeSecretHex) 49 | 50 | e := email{} 51 | if err := emailsRowScan(row, &e); err != nil { 52 | // TODO: is this the only error? 53 | return e, errorNoSuchUnsubscribeSecretHex 54 | } 55 | 56 | return e, nil 57 | } 58 | 59 | func emailGetHandler(w http.ResponseWriter, r *http.Request) { 60 | type request struct { 61 | UnsubscribeSecretHex *string `json:"unsubscribeSecretHex"` 62 | } 63 | 64 | var x request 65 | if err := bodyUnmarshal(r, &x); err != nil { 66 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 67 | return 68 | } 69 | 70 | e, err := emailGetByUnsubscribeSecretHex(*x.UnsubscribeSecretHex) 71 | if err != nil { 72 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 73 | return 74 | } 75 | 76 | bodyMarshal(w, response{"success": true, "email": e}) 77 | } 78 | -------------------------------------------------------------------------------- /api/page_update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func pageUpdate(p page) error { 8 | if p.Domain == "" { 9 | return errorMissingField 10 | } 11 | 12 | // fields to not update: 13 | // commentCount 14 | statement := ` 15 | INSERT INTO 16 | pages (domain, path, isLocked, stickyCommentHex) 17 | VALUES ($1, $2, $3, $4 ) 18 | ON CONFLICT (domain, path) DO 19 | UPDATE SET isLocked = $3, stickyCommentHex = $4; 20 | ` 21 | _, err := db.Exec(statement, p.Domain, p.Path, p.IsLocked, p.StickyCommentHex) 22 | if err != nil { 23 | logger.Errorf("error setting page attributes: %v", err) 24 | return errorInternal 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func pageUpdateHandler(w http.ResponseWriter, r *http.Request) { 31 | type request struct { 32 | CommenterToken *string `json:"commenterToken"` 33 | Domain *string `json:"domain"` 34 | Path *string `json:"path"` 35 | Attributes *page `json:"attributes"` 36 | } 37 | 38 | var x request 39 | if err := bodyUnmarshal(r, &x); err != nil { 40 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 41 | return 42 | } 43 | 44 | c, err := commenterGetByCommenterToken(*x.CommenterToken) 45 | if err != nil { 46 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 47 | return 48 | } 49 | 50 | domain := domainStrip(*x.Domain) 51 | 52 | isModerator, err := isDomainModerator(domain, c.Email) 53 | if err != nil { 54 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 55 | return 56 | } 57 | 58 | if !isModerator { 59 | bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()}) 60 | return 61 | } 62 | 63 | (*x.Attributes).Domain = *x.Domain 64 | (*x.Attributes).Path = *x.Path 65 | 66 | if err = pageUpdate(*x.Attributes); err != nil { 67 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 68 | return 69 | } 70 | 71 | bodyMarshal(w, response{"success": true}) 72 | } 73 | -------------------------------------------------------------------------------- /api/owner_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | var ownersRowColumns string = ` 6 | owners.ownerHex, 7 | owners.email, 8 | owners.name, 9 | owners.confirmedEmail, 10 | owners.joinDate 11 | ` 12 | 13 | func ownersRowScan(s sqlScanner, o *owner) error { 14 | return s.Scan( 15 | &o.OwnerHex, 16 | &o.Email, 17 | &o.Name, 18 | &o.ConfirmedEmail, 19 | &o.JoinDate, 20 | ) 21 | } 22 | 23 | func ownerGetByEmail(email string) (owner, error) { 24 | if email == "" { 25 | return owner{}, errorMissingField 26 | } 27 | 28 | statement := ` 29 | SELECT ` + ownersRowColumns + ` 30 | FROM owners 31 | WHERE email=$1; 32 | ` 33 | row := db.QueryRow(statement, email) 34 | 35 | var o owner 36 | if err := ownersRowScan(row, &o); err != nil { 37 | // TODO: Make sure this is actually no such email. 38 | return owner{}, errorNoSuchEmail 39 | } 40 | 41 | return o, nil 42 | } 43 | 44 | func ownerGetByOwnerToken(ownerToken string) (owner, error) { 45 | if ownerToken == "" { 46 | return owner{}, errorMissingField 47 | } 48 | 49 | statement := ` 50 | SELECT ` + ownersRowColumns + ` 51 | FROM owners 52 | WHERE owners.ownerHex IN ( 53 | SELECT ownerSessions.ownerHex FROM ownerSessions 54 | WHERE ownerSessions.ownerToken = $1 55 | ); 56 | ` 57 | row := db.QueryRow(statement, ownerToken) 58 | 59 | var o owner 60 | if err := ownersRowScan(row, &o); err != nil { 61 | logger.Errorf("cannot scan owner: %v\n", err) 62 | return owner{}, errorInternal 63 | } 64 | 65 | return o, nil 66 | } 67 | 68 | func ownerGetByOwnerHex(ownerHex string) (owner, error) { 69 | if ownerHex == "" { 70 | return owner{}, errorMissingField 71 | } 72 | 73 | statement := ` 74 | SELECT ` + ownersRowColumns + ` 75 | FROM owners 76 | WHERE ownerHex = $1; 77 | ` 78 | row := db.QueryRow(statement, ownerHex) 79 | 80 | var o owner 81 | if err := ownersRowScan(row, &o); err != nil { 82 | logger.Errorf("cannot scan owner: %v\n", err) 83 | return owner{}, errorInternal 84 | } 85 | 86 | return o, nil 87 | } 88 | -------------------------------------------------------------------------------- /Dockerfile.heroku: -------------------------------------------------------------------------------- 1 | # vim: syntax=dockerfile 2 | ## Commento Dockerfile for running on Heroku 3 | 4 | # 1. duplicate most of original docker image: https://gitlab.com/commento/commento/blob/master/Dockerfile 5 | # backend build (api server) 6 | FROM golang:1.12-alpine AS api-build 7 | RUN apk add --no-cache --update bash make git curl 8 | 9 | COPY ./api /go/src/commento/api/ 10 | WORKDIR /go/src/commento/api 11 | ENV GO111MODULE=on 12 | RUN make prod -j$(($(nproc) + 1)) 13 | 14 | 15 | # frontend build (html, js, css, images) 16 | FROM node:10-alpine AS frontend-build 17 | RUN apk add --no-cache --update bash make 18 | 19 | COPY ./frontend /commento/frontend 20 | WORKDIR /commento/frontend/ 21 | RUN make prod -j$(($(nproc) + 1)) 22 | 23 | 24 | # templates and db build 25 | FROM alpine:3.9 AS templates-db-build 26 | RUN apk add --no-cache --update bash make 27 | 28 | COPY ./templates /commento/templates 29 | WORKDIR /commento/templates 30 | RUN make prod -j$(($(nproc) + 1)) 31 | 32 | COPY ./db /commento/db 33 | WORKDIR /commento/db 34 | RUN export COMMENTO_POSTGRES=$(DATABASE_URL) # our addition 35 | RUN make prod -j$(($(nproc) + 1)) 36 | 37 | 38 | # final image 39 | FROM alpine:3.7 40 | RUN apk add --no-cache --update ca-certificates 41 | 42 | COPY --from=api-build /go/src/commento/api/build/prod/commento /commento/commento 43 | COPY --from=frontend-build /commento/frontend/build/prod/js /commento/js 44 | COPY --from=frontend-build /commento/frontend/build/prod/css /commento/css 45 | COPY --from=frontend-build /commento/frontend/build/prod/images /commento/images 46 | COPY --from=frontend-build /commento/frontend/build/prod/fonts /commento/fonts 47 | COPY --from=frontend-build /commento/frontend/build/prod/*.html /commento/ 48 | COPY --from=templates-db-build /commento/templates/build/prod/templates /commento/templates/ 49 | COPY --from=templates-db-build /commento/db/build/prod/db /commento/db/ 50 | 51 | COPY ./run.sh /commento/ 52 | RUN chmod +x /commento/run.sh 53 | WORKDIR /commento/ 54 | ENV COMMENTO_BIND_ADDRESS="0.0.0.0" 55 | CMD ["sh", "/commento/run.sh"] 56 | -------------------------------------------------------------------------------- /api/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | func versionPrint() error { 13 | logger.Infof("starting Commento %s", version) 14 | return nil 15 | } 16 | 17 | func versionCheckStart() error { 18 | go func() { 19 | printedError := false 20 | errorCount := 0 21 | latestSeen := "" 22 | 23 | for { 24 | time.Sleep(5 * time.Minute) 25 | 26 | data := url.Values{ 27 | "version": {version}, 28 | } 29 | 30 | resp, err := http.Post("https://version.commento.io/api/check", "application/x-www-form-urlencoded", bytes.NewBufferString(data.Encode())) 31 | if err != nil { 32 | errorCount++ 33 | // print the error only once; we don't want to spam the logs with this 34 | // every five minutes 35 | if !printedError && errorCount > 5 { 36 | logger.Errorf("error checking version: %v", err) 37 | printedError = true 38 | } 39 | continue 40 | } 41 | defer resp.Body.Close() 42 | 43 | body, err := ioutil.ReadAll(resp.Body) 44 | if err != nil { 45 | errorCount++ 46 | if !printedError && errorCount > 5 { 47 | logger.Errorf("error reading body: %s", err) 48 | printedError = true 49 | } 50 | continue 51 | } 52 | 53 | type response struct { 54 | Success bool `json:"success"` 55 | Message string `json:"message"` 56 | Latest string `json:"latest"` 57 | NewUpdate bool `json:"newUpdate"` 58 | } 59 | 60 | r := response{} 61 | json.Unmarshal(body, &r) 62 | if r.Success == false { 63 | errorCount++ 64 | if !printedError && errorCount > 5 { 65 | logger.Errorf("error checking version: %s", r.Message) 66 | printedError = true 67 | } 68 | continue 69 | } 70 | 71 | if r.NewUpdate && r.Latest != latestSeen { 72 | logger.Infof("New update available! Latest version: %s", r.Latest) 73 | latestSeen = r.Latest 74 | } 75 | 76 | errorCount = 0 77 | printedError = false 78 | } 79 | }() 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /frontend/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Commento: Login 9 | 10 | 11 | 16 | 17 | 24 | 25 |
26 |
27 |
28 |
29 | Login to continue 30 |
31 | 32 |
33 |
Email Address
34 | 35 |
36 | 37 |
38 |
Password
39 | 40 |
41 | 42 |
43 |
44 | 45 | 46 |
47 | 48 | Trouble logging in? Reset your password. 49 | Don't have an account yet? Sign up. 50 |
51 |
52 | 53 | [[[.Footer]]] 54 | 55 | -------------------------------------------------------------------------------- /api/domain_clear.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func domainClear(domain string) error { 8 | if domain == "" { 9 | return errorMissingField 10 | } 11 | 12 | statement := ` 13 | DELETE FROM votes 14 | USING comments 15 | WHERE comments.commentHex = votes.commentHex AND comments.domain = $1; 16 | ` 17 | _, err := db.Exec(statement, domain) 18 | if err != nil { 19 | logger.Errorf("cannot delete votes: %v", err) 20 | return errorInternal 21 | } 22 | 23 | statement = ` 24 | DELETE FROM comments 25 | WHERE comments.domain = $1; 26 | ` 27 | _, err = db.Exec(statement, domain) 28 | if err != nil { 29 | logger.Errorf(statement, domain) 30 | return errorInternal 31 | } 32 | 33 | statement = ` 34 | DELETE FROM pages 35 | WHERE pages.domain = $1; 36 | ` 37 | _, err = db.Exec(statement, domain) 38 | if err != nil { 39 | logger.Errorf(statement, domain) 40 | return errorInternal 41 | } 42 | 43 | return nil 44 | } 45 | 46 | func domainClearHandler(w http.ResponseWriter, r *http.Request) { 47 | type request struct { 48 | OwnerToken *string `json:"ownerToken"` 49 | Domain *string `json:"domain"` 50 | } 51 | 52 | var x request 53 | if err := bodyUnmarshal(r, &x); err != nil { 54 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 55 | return 56 | } 57 | 58 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 59 | if err != nil { 60 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 61 | return 62 | } 63 | 64 | domain := domainStrip(*x.Domain) 65 | isOwner, err := domainOwnershipVerify(o.OwnerHex, domain) 66 | if err != nil { 67 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 68 | return 69 | } 70 | 71 | if !isOwner { 72 | bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()}) 73 | return 74 | } 75 | 76 | if err = domainClear(*x.Domain); err != nil { 77 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 78 | return 79 | } 80 | 81 | bodyMarshal(w, response{"success": true}) 82 | } 83 | -------------------------------------------------------------------------------- /api/comment_vote_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestCommentVoteBasics(t *testing.T) { 9 | failTestOnError(t, setupTestEnv()) 10 | 11 | cr0, _ := commenterNew("test1@example.com", "Test1", "undefined", "http://example.com/photo.jpg", "google", "") 12 | cr1, _ := commenterNew("test2@example.com", "Test2", "undefined", "http://example.com/photo.jpg", "google", "") 13 | cr2, _ := commenterNew("test3@example.com", "Test3", "undefined", "http://example.com/photo.jpg", "google", "") 14 | 15 | c0, _ := commentNew(cr0, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC()) 16 | 17 | if err := commentVote(cr0, c0, 1); err != errorSelfVote { 18 | t.Errorf("expected err=errorSelfVote got err=%v", err) 19 | return 20 | } 21 | 22 | if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != 0 { 23 | t.Errorf("expected c[0].Score = 0 got c[0].Score = %d", c[0].Score) 24 | return 25 | } 26 | 27 | if err := commentVote(cr1, c0, -1); err != nil { 28 | t.Errorf("unexpected error voting: %v", err) 29 | return 30 | } 31 | 32 | if err := commentVote(cr2, c0, -1); err != nil { 33 | t.Errorf("unexpected error voting: %v", err) 34 | return 35 | } 36 | 37 | if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != -2 { 38 | t.Errorf("expected c[0].Score = -2 got c[0].Score = %d", c[0].Score) 39 | return 40 | } 41 | 42 | if err := commentVote(cr1, c0, -1); err != nil { 43 | t.Errorf("unexpected error voting: %v", err) 44 | return 45 | } 46 | 47 | if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != -2 { 48 | t.Errorf("expected c[0].Score = -2 got c[0].Score = %d", c[0].Score) 49 | return 50 | } 51 | 52 | if err := commentVote(cr1, c0, 0); err != nil { 53 | t.Errorf("unexpected error voting: %v", err) 54 | return 55 | } 56 | 57 | if c, _, _ := commentList("temp", "example.com", "/path.html", false); c[0].Score != -1 { 58 | t.Errorf("expected c[0].Score = -1 got c[0].Score = %d", c[0].Score) 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /api/comment_edit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func commentEdit(commentHex string, markdown string, url string) (string, error) { 8 | if commentHex == "" { 9 | return "", errorMissingField 10 | } 11 | 12 | html := markdownToHtml(markdown) 13 | 14 | statement := ` 15 | UPDATE comments 16 | SET markdown = $2, html = $3 17 | WHERE commentHex=$1; 18 | ` 19 | _, err := db.Exec(statement, commentHex, markdown, html) 20 | 21 | if err != nil { 22 | // TODO: make sure this is the error is actually non-existant commentHex 23 | return "", errorNoSuchComment 24 | } 25 | 26 | hub.broadcast <- []byte(url) 27 | 28 | return html, nil 29 | } 30 | 31 | func commentEditHandler(w http.ResponseWriter, r *http.Request) { 32 | type request struct { 33 | CommenterToken *string `json:"commenterToken"` 34 | CommentHex *string `json:"commentHex"` 35 | Markdown *string `json:"markdown"` 36 | } 37 | 38 | var x request 39 | if err := bodyUnmarshal(r, &x); err != nil { 40 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 41 | return 42 | } 43 | 44 | domain, path, err := commentDomainPathGet(*x.CommentHex) 45 | if err != nil { 46 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 47 | return 48 | } 49 | 50 | c, err := commenterGetByCommenterToken(*x.CommenterToken) 51 | if err != nil { 52 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 53 | return 54 | } 55 | 56 | cm, err := commentGetByCommentHex(*x.CommentHex) 57 | if err != nil { 58 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 59 | return 60 | } 61 | 62 | if cm.CommenterHex != c.CommenterHex { 63 | bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()}) 64 | return 65 | } 66 | 67 | html, err := commentEdit(*x.CommentHex, *x.Markdown, domain + path) 68 | if err != nil { 69 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 70 | return 71 | } 72 | 73 | bodyMarshal(w, response{"success": true, "html": html}) 74 | } 75 | -------------------------------------------------------------------------------- /api/owner_login.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | func ownerLogin(email string, password string) (string, error) { 10 | if email == "" || password == "" { 11 | return "", errorMissingField 12 | } 13 | 14 | statement := ` 15 | SELECT ownerHex, confirmedEmail, passwordHash 16 | FROM owners 17 | WHERE email=$1; 18 | ` 19 | row := db.QueryRow(statement, email) 20 | 21 | var ownerHex string 22 | var confirmedEmail bool 23 | var passwordHash string 24 | if err := row.Scan(&ownerHex, &confirmedEmail, &passwordHash); err != nil { 25 | return "", errorInvalidEmailPassword 26 | } 27 | 28 | if !confirmedEmail { 29 | return "", errorUnconfirmedEmail 30 | } 31 | 32 | if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil { 33 | // TODO: is this the only possible error? 34 | return "", errorInvalidEmailPassword 35 | } 36 | 37 | ownerToken, err := randomHex(32) 38 | if err != nil { 39 | logger.Errorf("cannot create ownerToken: %v", err) 40 | return "", errorInternal 41 | } 42 | 43 | statement = ` 44 | INSERT INTO 45 | ownerSessions (ownerToken, ownerHex, loginDate) 46 | VALUES ($1, $2, $3 ); 47 | ` 48 | _, err = db.Exec(statement, ownerToken, ownerHex, time.Now().UTC()) 49 | if err != nil { 50 | logger.Errorf("cannot insert ownerSession: %v\n", err) 51 | return "", errorInternal 52 | } 53 | 54 | return ownerToken, nil 55 | } 56 | 57 | func ownerLoginHandler(w http.ResponseWriter, r *http.Request) { 58 | type request struct { 59 | Email *string `json:"email"` 60 | Password *string `json:"password"` 61 | } 62 | 63 | var x request 64 | if err := bodyUnmarshal(r, &x); err != nil { 65 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 66 | return 67 | } 68 | 69 | ownerToken, err := ownerLogin(*x.Email, *x.Password) 70 | if err != nil { 71 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 72 | return 73 | } 74 | 75 | bodyMarshal(w, response{"success": true, "ownerToken": ownerToken}) 76 | } 77 | -------------------------------------------------------------------------------- /api/reset.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | "net/http" 6 | ) 7 | 8 | func reset(resetHex string, password string) (string, error) { 9 | if resetHex == "" || password == "" { 10 | return "", errorMissingField 11 | } 12 | 13 | statement := ` 14 | SELECT hex, entity 15 | FROM resetHexes 16 | WHERE resetHex = $1; 17 | ` 18 | row := db.QueryRow(statement, resetHex) 19 | 20 | var hex string 21 | var entity string 22 | if err := row.Scan(&hex, &entity); err != nil { 23 | // TODO: is this the only error? 24 | return "", errorNoSuchResetToken 25 | } 26 | 27 | passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 28 | if err != nil { 29 | logger.Errorf("cannot generate hash from password: %v\n", err) 30 | return "", errorInternal 31 | } 32 | 33 | if entity == "owner" { 34 | statement = ` 35 | UPDATE owners SET passwordHash = $1, confirmedEmail=true 36 | WHERE ownerHex = $2; 37 | ` 38 | } else { 39 | statement = ` 40 | UPDATE commenters SET passwordHash = $1 41 | WHERE commenterHex = $2; 42 | ` 43 | } 44 | 45 | _, err = db.Exec(statement, string(passwordHash), hex) 46 | if err != nil { 47 | logger.Errorf("cannot change %s's password: %v\n", entity, err) 48 | return "", errorInternal 49 | } 50 | 51 | statement = ` 52 | DELETE FROM resetHexes 53 | WHERE resetHex = $1; 54 | ` 55 | _, err = db.Exec(statement, resetHex) 56 | if err != nil { 57 | logger.Warningf("cannot remove resetHex: %v\n", err) 58 | } 59 | 60 | return entity, nil 61 | } 62 | 63 | func resetHandler(w http.ResponseWriter, r *http.Request) { 64 | type request struct { 65 | ResetHex *string `json:"resetHex"` 66 | Password *string `json:"password"` 67 | } 68 | 69 | var x request 70 | if err := bodyUnmarshal(r, &x); err != nil { 71 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 72 | return 73 | } 74 | 75 | entity, err := reset(*x.ResetHex, *x.Password) 76 | if err != nil { 77 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 78 | return 79 | } 80 | 81 | bodyMarshal(w, response{"success": true, "entity": entity}) 82 | } 83 | -------------------------------------------------------------------------------- /api/commenter_update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func commenterUpdate(commenterHex string, email string, name string, link string, photo string, provider string) error { 8 | if email == "" || name == "" || photo == "" || provider == "" { 9 | return errorMissingField 10 | } 11 | 12 | // See utils_sanitise.go's documentation on isHttpsUrl. This is not a URL 13 | // validator, just an XSS preventor. 14 | // TODO: reject URLs instead of malforming them. 15 | if link == "" { 16 | link = "undefined" 17 | } else if link != "undefined" && !isHttpsUrl(link) { 18 | link = "https://" + link 19 | } 20 | 21 | statement := ` 22 | UPDATE commenters 23 | SET email = $3, name = $4, link = $5, photo = $6 24 | WHERE commenterHex = $1 and provider = $2; 25 | ` 26 | _, err := db.Exec(statement, commenterHex, provider, email, name, link, photo) 27 | if err != nil { 28 | logger.Errorf("cannot update commenter: %v", err) 29 | return errorInternal 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func commenterUpdateHandler(w http.ResponseWriter, r *http.Request) { 36 | type request struct { 37 | CommenterToken *string `json:"commenterToken"` 38 | Name *string `json:"name"` 39 | Email *string `json:"email"` 40 | Link *string `json:"link"` 41 | Photo *string `json:"photo"` 42 | } 43 | 44 | var x request 45 | if err := bodyUnmarshal(r, &x); err != nil { 46 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 47 | return 48 | } 49 | 50 | c, err := commenterGetByCommenterToken(*x.CommenterToken) 51 | if err != nil { 52 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 53 | return 54 | } 55 | 56 | if c.Provider != "commento" { 57 | bodyMarshal(w, response{"success": false, "message": errorCannotUpdateOauthProfile.Error()}) 58 | return 59 | } 60 | 61 | *x.Email = c.Email 62 | 63 | if err = commenterUpdate(c.CommenterHex, *x.Email, *x.Name, *x.Link, *x.Photo, c.Provider); err != nil { 64 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 65 | return 66 | } 67 | 68 | bodyMarshal(w, response{"success": true}) 69 | } 70 | -------------------------------------------------------------------------------- /frontend/sass/commento-login.scss: -------------------------------------------------------------------------------- 1 | @import "colors-main.scss"; 2 | 3 | .commento-login-box-container { 4 | display: flex; 5 | justify-content: center; 6 | position: relative; 7 | width: 100%; 8 | height: 0px; 9 | overflow: visible; 10 | 11 | .commento-login-box { 12 | width: 90%; 13 | max-width: 500px; 14 | min-height: 100px; 15 | background: $gray-1; 16 | z-index: 100; 17 | position: absolute; 18 | top: 8px; 19 | padding: 16px; 20 | opacity: 1; 21 | transition: opacity 0.2s; 22 | 23 | @import "commento-oauth.scss"; 24 | 25 | hr { 26 | border: none; 27 | background: $gray-2; 28 | height: 1px; 29 | margin: 24px 0px; 30 | } 31 | 32 | .commento-login-box-subtitle { 33 | color: $gray-6; 34 | text-align: center; 35 | margin: 12px 0px; 36 | font-size: 15px; 37 | } 38 | 39 | @import "email-main.scss"; 40 | 41 | .commento-forgot-link-container, 42 | .commento-login-link-container { 43 | margin: 16px; 44 | width: calc(100% - 32px); 45 | text-align: center; 46 | } 47 | 48 | .commento-forgot-link, 49 | .commento-login-link { 50 | font-size: 14px; 51 | font-weight: bold; 52 | border-bottom: none; 53 | } 54 | 55 | .commento-forgot-link { 56 | font-size: 13px; 57 | color: $gray-6; 58 | font-weight: normal; 59 | } 60 | 61 | .commento-login-box-close { 62 | position: absolute; 63 | right: 16px; 64 | top: 16px; 65 | width: 16px; 66 | height: 16px; 67 | opacity: 0.3; 68 | } 69 | 70 | .commento-login-box-close:hover { 71 | opacity: 1; 72 | cursor: pointer; 73 | } 74 | 75 | .commento-login-box-close:before, .commento-login-box-close:after { 76 | position: absolute; 77 | left: 7px; 78 | content: ' '; 79 | height: 17px; 80 | width: 2px; 81 | background-color: $gray-7; 82 | } 83 | 84 | .commento-login-box-close:before { 85 | transform: rotate(45deg); 86 | } 87 | 88 | .commento-login-box-close:after { 89 | transform: rotate(-45deg); 90 | } 91 | } 92 | } 93 | --------------------------------------------------------------------------------