├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── Dockerfile.heroku ├── LICENSE ├── Makefile ├── README.md ├── api ├── Makefile ├── akismet.go ├── client.go ├── comment.go ├── comment_approve.go ├── comment_approve_test.go ├── comment_count.go ├── comment_count_test.go ├── comment_delete.go ├── comment_delete_test.go ├── comment_domain_path_get.go ├── comment_domain_path_get_test.go ├── comment_edit.go ├── comment_get.go ├── comment_list.go ├── comment_list_test.go ├── comment_new.go ├── comment_new_test.go ├── comment_ownership_verify.go ├── comment_ownership_verify_test.go ├── comment_statistics.go ├── comment_vote.go ├── comment_vote_test.go ├── commenter.go ├── commenter_get.go ├── commenter_get_test.go ├── commenter_login.go ├── commenter_login_test.go ├── commenter_new.go ├── commenter_new_test.go ├── commenter_photo.go ├── commenter_self.go ├── commenter_session.go ├── commenter_session_new.go ├── commenter_session_new_test.go ├── commenter_session_update.go ├── commenter_session_update_test.go ├── commenter_update.go ├── config.go ├── config_file.go ├── config_file_test.go ├── config_test.go ├── constants.go ├── cron_domain_export_cleanup.go ├── cron_sso_token.go ├── cron_views_cleanup.go ├── database.go ├── database_connect.go ├── database_migrate.go ├── database_migrate_email_notifications.go ├── domain.go ├── domain_clear.go ├── domain_delete.go ├── domain_delete_test.go ├── domain_export.go ├── domain_export_download.go ├── domain_get.go ├── domain_get_test.go ├── domain_import_commento.go ├── domain_import_commento_test.go ├── domain_import_disqus.go ├── domain_list.go ├── domain_list_test.go ├── domain_moderator.go ├── domain_moderator_delete.go ├── domain_moderator_delete_test.go ├── domain_moderator_new.go ├── domain_moderator_new_test.go ├── domain_moderator_test.go ├── domain_new.go ├── domain_new_test.go ├── domain_ownership_verify.go ├── domain_ownership_verify_test.go ├── domain_sso.go ├── domain_statistics.go ├── domain_update.go ├── domain_update_test.go ├── domain_view_record.go ├── email.go ├── email_get.go ├── email_moderate.go ├── email_new.go ├── email_notification.go ├── email_notification_new.go ├── email_update.go ├── errors.go ├── forgot.go ├── go.mod ├── go.sum ├── hub.go ├── main.go ├── markdown.go ├── markdown_html.go ├── markdown_html_test.go ├── oauth.go ├── oauth_github.go ├── oauth_github_callback.go ├── oauth_github_redirect.go ├── oauth_gitlab.go ├── oauth_gitlab_callback.go ├── oauth_gitlab_redirect.go ├── oauth_google.go ├── oauth_google_callback.go ├── oauth_google_redirect.go ├── oauth_google_test.go ├── oauth_sso.go ├── oauth_sso_callback.go ├── oauth_sso_redirect.go ├── oauth_twitter.go ├── oauth_twitter_callback.go ├── oauth_twitter_redirect.go ├── owner.go ├── owner_confirm_hex.go ├── owner_confirm_hex_test.go ├── owner_delete.go ├── owner_get.go ├── owner_get_test.go ├── owner_login.go ├── owner_login_test.go ├── owner_new.go ├── owner_new_test.go ├── owner_self.go ├── page.go ├── page_get.go ├── page_get_test.go ├── page_new.go ├── page_new_test.go ├── page_title.go ├── page_update.go ├── page_update_test.go ├── reset.go ├── router.go ├── router_api.go ├── router_static.go ├── sigint.go ├── smtp_configure.go ├── smtp_configure_test.go ├── smtp_domain_export.go ├── smtp_domain_export_error.go ├── smtp_email_notification.go ├── smtp_owner_confirm_hex.go ├── smtp_reset_hex.go ├── smtp_templates.go ├── testing.go ├── utils_crypto.go ├── utils_crypto_test.go ├── utils_gzip.go ├── utils_html.go ├── utils_http.go ├── utils_logging.go ├── utils_logging_test.go ├── utils_misc.go ├── utils_sanitise.go ├── utils_sanitise_test.go ├── utils_sql.go └── version.go ├── app.json ├── db ├── 20180416163802-init-schema.sql ├── 20180610215858-commenter-password.sql ├── 20180620083655-session-token-renamme.sql ├── 20180724125115-remove-config.sql ├── 20180922181651-page-attributes.sql ├── 20180923002745-comment-count.sql ├── 20180923004309-comment-count-build.sql ├── 20181007230906-store-version.sql ├── 20181007231407-v1.1.4.sql ├── 20181218183803-sticky-comments.sql ├── 20181228114101-v1.4.0.sql ├── 20181228114101-v1.4.1.sql ├── 20190122235525-anonymous-moderation-default.sql ├── 20190123002724-v1.4.2.sql ├── 20190131002240-export.sql ├── 20190204180609-v1.5.0.sql ├── 20190213033530-email-notifications.sql ├── 20190218173502-v1.6.0.sql ├── 20190218183556-v1.6.1.sql ├── 20190219001130-v1.6.2.sql ├── 20190418210855-configurable-auth.sql ├── 20190420181913-sso.sql ├── 20190420231030-sso-tokens.sql ├── 20190501201032-v1.7.0.sql ├── 20190505191006-comment-count-decrease.sql ├── 20190508222848-reset-count.sql ├── 20190606000842-reset-hex.sql ├── 20190913175445-delete-comments.sql ├── 20191204173000-sort-method.sql ├── 20200730134007-comment-count-update.sql ├── Makefile └── new.sh ├── docker-compose.yml ├── etc ├── bsd-rc │ └── commento └── linux-systemd │ └── commento.service ├── frontend ├── .eslintrc ├── .gitignore ├── Makefile ├── confirm-email.html ├── dashboard.html ├── fonts │ ├── source-sans-300-cyrillic-ext.woff2 │ ├── source-sans-300-cyrillic.woff2 │ ├── source-sans-300-greek-ext.woff2 │ ├── source-sans-300-greek.woff2 │ ├── source-sans-300-latin-ext.woff2 │ ├── source-sans-300-latin.woff2 │ ├── source-sans-300-vietnamese.woff2 │ ├── source-sans-400-cyrillic-ext.woff2 │ ├── source-sans-400-cyrillic.woff2 │ ├── source-sans-400-greek-ext.woff2 │ ├── source-sans-400-greek.woff2 │ ├── source-sans-400-latin-ext.woff2 │ ├── source-sans-400-latin.woff2 │ ├── source-sans-400-vietnamese.woff2 │ ├── source-sans-700-cyrillic-ext.woff2 │ ├── source-sans-700-cyrillic.woff2 │ ├── source-sans-700-greek-ext.woff2 │ ├── source-sans-700-greek.woff2 │ ├── source-sans-700-latin-ext.woff2 │ ├── source-sans-700-latin.woff2 │ └── source-sans-700-vietnamese.woff2 ├── footer.html ├── forgot.html ├── gulpfile.js ├── images │ ├── 120x120.png │ ├── banner.png │ ├── logo.svg │ └── tree.svg ├── js │ ├── auth-common.js │ ├── commento.js │ ├── constants.js │ ├── count.js │ ├── dashboard-danger.js │ ├── dashboard-domain.js │ ├── dashboard-export.js │ ├── dashboard-general.js │ ├── dashboard-import.js │ ├── dashboard-installation.js │ ├── dashboard-moderation.js │ ├── dashboard-setting.js │ ├── dashboard-statistics.js │ ├── dashboard.js │ ├── errors.js │ ├── forgot.js │ ├── http.js │ ├── login.js │ ├── logout.js │ ├── morphdom.js │ ├── profile.js │ ├── reset.js │ ├── self.js │ ├── settings.js │ ├── signup.js │ ├── unsubscribe.js │ └── utils.js ├── login.html ├── logout.html ├── package.json ├── profile.html ├── reset.html ├── sass │ ├── auth-main.scss │ ├── auth.scss │ ├── button.scss │ ├── chartist.scss │ ├── checkbox.scss │ ├── colors-main.scss │ ├── commento-card.scss │ ├── commento-common.scss │ ├── commento-footer.scss │ ├── commento-input.scss │ ├── commento-logged.scss │ ├── commento-login.scss │ ├── commento-mod-tools.scss │ ├── commento-oauth.scss │ ├── commento-options.scss │ ├── commento.scss │ ├── common-main.scss │ ├── dashboard-main.scss │ ├── dashboard.scss │ ├── email-main.scss │ ├── navbar-main.scss │ ├── source-sans.scss │ ├── tomorrow.scss │ ├── unsubscribe-main.scss │ └── unsubscribe.scss ├── settings.html ├── signup.html ├── unsubscribe.html └── yarn.lock ├── heroku.yml ├── run.sh ├── scripts ├── autoserve └── gitlab-ci-build-prescript ├── templates ├── Makefile ├── confirm-hex.txt ├── domain-export-error.txt ├── domain-export.txt ├── email-notification.txt └── reset-hex.txt └── update_repo.sh /.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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Commento, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /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/akismet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/adtac/go-akismet/akismet" 5 | "os" 6 | ) 7 | 8 | func isSpam(domain string, userIp string, userAgent string, name string, email string, url string, markdown string) bool { 9 | akismetKey := os.Getenv("AKISMET_KEY") 10 | if akismetKey == "" { 11 | return false 12 | } 13 | 14 | res, err := akismet.Check(&akismet.Comment{ 15 | Blog: domain, 16 | UserIP: userIp, 17 | UserAgent: userAgent, 18 | CommentType: "comment", 19 | CommentAuthor: name, 20 | CommentAuthorEmail: email, 21 | CommentAuthorURL: url, 22 | CommentContent: markdown, 23 | }, akismetKey) 24 | 25 | if err != nil { 26 | logger.Errorf("error: cannot validate commenet using Akismet: %v", err) 27 | return true 28 | } 29 | 30 | return res 31 | } 32 | -------------------------------------------------------------------------------- /api/comment.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type comment struct { 8 | CommentHex string `json:"commentHex"` 9 | Domain string `json:"domain,omitempty"` 10 | Path string `json:"url,omitempty"` 11 | CommenterHex string `json:"commenterHex"` 12 | Markdown string `json:"markdown"` 13 | Html string `json:"html"` 14 | ParentHex string `json:"parentHex"` 15 | Score int `json:"score"` 16 | State string `json:"state,omitempty"` 17 | CreationDate time.Time `json:"creationDate"` 18 | Direction int `json:"direction"` 19 | Deleted bool `json:"deleted"` 20 | } 21 | -------------------------------------------------------------------------------- /api/comment_approve_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestCommentApproveBasics(t *testing.T) { 9 | failTestOnError(t, setupTestEnv()) 10 | 11 | commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google", "") 12 | 13 | commentHex, _ := commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC()) 14 | 15 | if err := commentApprove(commentHex); err != nil { 16 | t.Errorf("unexpected error approving comment: %v", err) 17 | return 18 | } 19 | 20 | if c, _, _ := commentList("anonymous", "example.com", "/path.html", true); c[0].State != "approved" { 21 | t.Errorf("expected state = approved got state = %s", c[0].State) 22 | return 23 | } 24 | } 25 | 26 | func TestCommentApproveEmpty(t *testing.T) { 27 | failTestOnError(t, setupTestEnv()) 28 | 29 | if err := commentApprove(""); err == nil { 30 | t.Errorf("expected error not found approving comment with empty commentHex") 31 | return 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /api/comment_delete_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestCommentDeleteBasics(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 | commentNew("temp-commenter-hex", "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC()) 13 | 14 | if err := commentDelete(commentHex); err != nil { 15 | t.Errorf("unexpected error deleting comment: %v", err) 16 | return 17 | } 18 | 19 | c, _, _ := commentList("temp-commenter-hex", "example.com", "/path.html", false) 20 | 21 | if len(c) != 0 { 22 | t.Errorf("expected no comments found %d comments", len(c)) 23 | return 24 | } 25 | } 26 | 27 | func TestCommentDeleteEmpty(t *testing.T) { 28 | failTestOnError(t, setupTestEnv()) 29 | 30 | if err := commentDelete(""); err == nil { 31 | t.Errorf("expected error deleting comment with empty commentHex") 32 | return 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/comment_domain_path_get_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestCommentDomainPathGetBasics(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 | domain, path, err := commentDomainPathGet(commentHex) 14 | if err != nil { 15 | t.Errorf("unexpected error getting domain by hex: %v", err) 16 | return 17 | } 18 | 19 | if domain != "example.com" { 20 | t.Errorf("expected domain=example.com got domain=%s", domain) 21 | return 22 | } 23 | 24 | if path != "/path.html" { 25 | t.Errorf("expected path=/path.html got path=%s", path) 26 | return 27 | } 28 | } 29 | 30 | func TestCommentDomainGetEmpty(t *testing.T) { 31 | failTestOnError(t, setupTestEnv()) 32 | 33 | if _, _, err := commentDomainPathGet(""); err == nil { 34 | t.Errorf("expected error not found getting domain with empty commentHex") 35 | return 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /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/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/comment_ownership_verify.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func commentOwnershipVerify(commenterHex string, commentHex string) (bool, error) { 6 | if commenterHex == "" || commentHex == "" { 7 | return false, errorMissingField 8 | } 9 | 10 | statement := ` 11 | SELECT EXISTS ( 12 | SELECT 1 13 | FROM comments 14 | WHERE commenterHex=$1 AND commentHex=$2 15 | ); 16 | ` 17 | row := db.QueryRow(statement, commenterHex, commentHex) 18 | 19 | var exists bool 20 | if err := row.Scan(&exists); err != nil { 21 | logger.Errorf("cannot query if comment owner: %v", err) 22 | return false, errorInternal 23 | } 24 | 25 | return exists, nil 26 | } 27 | -------------------------------------------------------------------------------- /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/comment_statistics.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func commentStatistics(domain string) ([]int64, error) { 6 | statement := ` 7 | SELECT COUNT(comments.creationDate) 8 | FROM ( 9 | SELECT to_char(date_trunc('day', (current_date - offs)), 'YYYY-MM-DD') AS date 10 | FROM generate_series(0, 30, 1) AS offs 11 | ) gen LEFT OUTER JOIN comments 12 | ON gen.date = to_char(date_trunc('day', comments.creationDate), 'YYYY-MM-DD') AND 13 | comments.domain=$1 14 | GROUP BY gen.date 15 | ORDER BY gen.date; 16 | ` 17 | rows, err := db.Query(statement, domain) 18 | if err != nil { 19 | logger.Errorf("cannot get daily views: %v", err) 20 | return []int64{}, errorInternal 21 | } 22 | 23 | defer rows.Close() 24 | 25 | last30Days := []int64{} 26 | for rows.Next() { 27 | var count int64 28 | if err = rows.Scan(&count); err != nil { 29 | logger.Errorf("cannot get daily comments for the last month: %v", err) 30 | return make([]int64, 0), errorInternal 31 | } 32 | last30Days = append(last30Days, count) 33 | } 34 | 35 | return last30Days, nil 36 | } 37 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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_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/commenter_self.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func commenterSelfHandler(w http.ResponseWriter, r *http.Request) { 8 | type request struct { 9 | CommenterToken *string `json:"commenterToken"` 10 | } 11 | 12 | var x request 13 | if err := bodyUnmarshal(r, &x); err != nil { 14 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 15 | return 16 | } 17 | 18 | c, err := commenterGetByCommenterToken(*x.CommenterToken) 19 | if err != nil { 20 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 21 | return 22 | } 23 | 24 | e, err := emailGet(c.Email) 25 | if err != nil { 26 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 27 | return 28 | } 29 | 30 | bodyMarshal(w, response{"success": true, "commenter": c, "email": e}) 31 | } 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/commenter_session_new.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | func commenterTokenNew() (string, error) { 9 | commenterToken, err := randomHex(32) 10 | if err != nil { 11 | logger.Errorf("cannot create commenterToken: %v", err) 12 | return "", errorInternal 13 | } 14 | 15 | statement := ` 16 | INSERT INTO 17 | commenterSessions (commenterToken, creationDate) 18 | VALUES ($1, $2 ); 19 | ` 20 | _, err = db.Exec(statement, commenterToken, time.Now().UTC()) 21 | if err != nil { 22 | logger.Errorf("cannot insert new commenterToken: %v", err) 23 | return "", errorInternal 24 | } 25 | 26 | return commenterToken, nil 27 | } 28 | 29 | func commenterTokenNewHandler(w http.ResponseWriter, r *http.Request) { 30 | commenterToken, err := commenterTokenNew() 31 | if err != nil { 32 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 33 | return 34 | } 35 | 36 | bodyMarshal(w, response{"success": true, "commenterToken": commenterToken}) 37 | } 38 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /api/config_file.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func configFileLoad(filepath string) error { 10 | file, err := os.Open(filepath) 11 | if err != nil { 12 | return err 13 | } 14 | 15 | defer file.Close() 16 | 17 | num := 0 18 | scanner := bufio.NewScanner(file) 19 | for scanner.Scan() { 20 | num += 1 21 | 22 | line := strings.TrimSpace(scanner.Text()) 23 | if line == "" { 24 | continue 25 | } 26 | 27 | if strings.HasPrefix(line, "#") { 28 | continue 29 | } 30 | 31 | i := strings.Index(line, "=") 32 | if i == -1 { 33 | logger.Errorf("%s: line %d: neither a comment nor a valid setting", filepath, num) 34 | return errorInvalidConfigFile 35 | } 36 | 37 | key := line[:i] 38 | value := line[i+1:] 39 | 40 | if !strings.HasPrefix(key, "COMMENTO_") { 41 | continue 42 | } 43 | 44 | if os.Getenv(key) != "" { 45 | // Config files have lower precedence. 46 | continue 47 | } 48 | 49 | os.Setenv(key, value) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /api/constants.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var version string 4 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | var db *sql.DB 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/database_migrate_email_notifications.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func migrateEmails() error { 6 | statement := ` 7 | SELECT commenters.email 8 | FROM commenters 9 | UNION 10 | SELECT owners.email 11 | FROM owners 12 | UNION 13 | SELECT moderators.email 14 | FROM moderators; 15 | ` 16 | rows, err := db.Query(statement) 17 | if err != nil { 18 | logger.Errorf("cannot get comments: %v", err) 19 | return errorDatabaseMigration 20 | } 21 | defer rows.Close() 22 | 23 | for rows.Next() { 24 | var email string 25 | if err = rows.Scan(&email); err != nil { 26 | logger.Errorf("cannot get email from tables during migration: %v", err) 27 | return errorDatabaseMigration 28 | } 29 | 30 | if err = emailNew(email); err != nil { 31 | logger.Errorf("cannot insert email during migration: %v", err) 32 | return errorDatabaseMigration 33 | } 34 | } 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/domain_delete_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainDeleteBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | domainNew("temp-owner-hex", "Example", "example.com") 11 | domainNew("temp-owner-hex", "Example", "example2.com") 12 | 13 | if err := domainDelete("example.com"); err != nil { 14 | t.Errorf("unexpected error deleting domain: %v", err) 15 | return 16 | } 17 | 18 | d, _ := domainList("temp-owner-hex") 19 | 20 | if len(d) != 1 { 21 | t.Errorf("expected number of domains to be 1 got %d", len(d)) 22 | return 23 | } 24 | 25 | if d[0].Domain != "example2.com" { 26 | t.Errorf("expected first domain to be example2.com got %s", d[0].Domain) 27 | return 28 | } 29 | } 30 | 31 | func TestDomainDeleteEmpty(t *testing.T) { 32 | failTestOnError(t, setupTestEnv()) 33 | 34 | if err := domainDelete(""); err == nil { 35 | t.Errorf("expected error not found when deleting with empty domain") 36 | return 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/domain_export_download.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | func domainExportDownloadHandler(w http.ResponseWriter, r *http.Request) { 10 | exportHex := r.FormValue("exportHex") 11 | if exportHex == "" { 12 | fmt.Fprintf(w, "Error: empty exportHex\n") 13 | return 14 | } 15 | 16 | statement := ` 17 | SELECT domain, binData, creationDate 18 | FROM exports 19 | WHERE exportHex = $1; 20 | ` 21 | row := db.QueryRow(statement, exportHex) 22 | 23 | var domain string 24 | var binData []byte 25 | var creationDate time.Time 26 | if err := row.Scan(&domain, &binData, &creationDate); err != nil { 27 | fmt.Fprintf(w, "Error: that exportHex does not exist\n") 28 | } 29 | 30 | w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s-%v.json.gz"`, domain, creationDate.Unix())) 31 | w.Write(binData) 32 | } 33 | -------------------------------------------------------------------------------- /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_get_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainGetBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | domainNew("temp-owner-hex", "Example", "example.com") 11 | 12 | d, err := domainGet("example.com") 13 | if err != nil { 14 | t.Errorf("unexpected error getting domain: %v", err) 15 | return 16 | } 17 | 18 | if d.Name != "Example" { 19 | t.Errorf("expected name=Example got name=%s", d.Name) 20 | return 21 | } 22 | } 23 | 24 | func TestDomainGetEmpty(t *testing.T) { 25 | failTestOnError(t, setupTestEnv()) 26 | 27 | if _, err := domainGet(""); err == nil { 28 | t.Errorf("expected error not found when getting with empty domain") 29 | return 30 | } 31 | } 32 | 33 | func TestDomainGetDNE(t *testing.T) { 34 | failTestOnError(t, setupTestEnv()) 35 | 36 | if _, err := domainGet("example.com"); err == nil { 37 | t.Errorf("expected error not found when getting non-existant domain") 38 | return 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /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/domain_list_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainListBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | domainNew("temp-owner-hex", "Example", "example.com") 11 | domainNew("temp-owner-hex", "Example", "example2.com") 12 | 13 | d, err := domainList("temp-owner-hex") 14 | if err != nil { 15 | t.Errorf("unexpected error listing domains: %v", err) 16 | return 17 | } 18 | 19 | if len(d) != 2 { 20 | t.Errorf("expected number of domains to be 2 got %d", len(d)) 21 | return 22 | } 23 | 24 | if d[0].Domain != "example.com" { 25 | t.Errorf("expected first domain to be example.com got %s", d[0].Domain) 26 | return 27 | } 28 | 29 | if d[1].Domain != "example2.com" { 30 | t.Errorf("expected first domain to be example2.com got %s", d[1].Domain) 31 | return 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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.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/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/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_moderator_new_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainModeratorNewBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | if err := domainModeratorNew("example.com", "test@example.com"); err != nil { 11 | t.Errorf("unexpected error creating new domain moderator: %v", err) 12 | return 13 | } 14 | } 15 | 16 | func TestDomainModeratorNewEmpty(t *testing.T) { 17 | failTestOnError(t, setupTestEnv()) 18 | 19 | if err := domainModeratorNew("example.com", ""); err == nil { 20 | t.Errorf("expected error not found when creating new moderator with empty email") 21 | return 22 | } 23 | 24 | if err := domainModeratorNew("", "test@example.com"); err == nil { 25 | t.Errorf("expected error not found when creating new moderator with empty domain") 26 | return 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /api/domain_ownership_verify_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDomainVerifyOwnershipBasics(t *testing.T) { 8 | failTestOnError(t, setupTestEnv()) 9 | 10 | ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2") 11 | ownerLogin("test@example.com", "hunter2") 12 | 13 | domainNew(ownerHex, "Example", "example.com") 14 | 15 | isOwner, err := domainOwnershipVerify(ownerHex, "example.com") 16 | if err != nil { 17 | t.Errorf("error checking ownership: %v", err) 18 | return 19 | } 20 | 21 | if !isOwner { 22 | t.Errorf("expected isOwner=true got isOwner=false") 23 | return 24 | } 25 | 26 | otherOwnerHex, _ := ownerNew("test2@example.com", "Test2", "hunter2") 27 | ownerLogin("test2@example.com", "hunter2") 28 | 29 | isOwner, err = domainOwnershipVerify(otherOwnerHex, "example.com") 30 | if err != nil { 31 | t.Errorf("error checking ownership: %v", err) 32 | return 33 | } 34 | 35 | if isOwner { 36 | t.Errorf("expected isOwner=false got isOwner=true") 37 | return 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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/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/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/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 | -------------------------------------------------------------------------------- /api/email_new.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func emailNew(email string) error { 8 | unsubscribeSecretHex, err := randomHex(32) 9 | if err != nil { 10 | return errorInternal 11 | } 12 | 13 | statement := ` 14 | INSERT INTO 15 | emails (email, unsubscribeSecretHex, lastEmailNotificationDate) 16 | VALUES ($1, $2, $3 ) 17 | ON CONFLICT DO NOTHING; 18 | ` 19 | _, err = db.Exec(statement, email, unsubscribeSecretHex, time.Now().UTC()) 20 | if err != nil { 21 | logger.Errorf("cannot insert email into emails: %v", err) 22 | return errorInternal 23 | } 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/email_update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func emailUpdate(e email) error { 8 | statement := ` 9 | UPDATE emails 10 | SET sendReplyNotifications = $3, sendModeratorNotifications = $4 11 | WHERE email = $1 AND unsubscribeSecretHex = $2; 12 | ` 13 | _, err := db.Exec(statement, e.Email, e.UnsubscribeSecretHex, e.SendReplyNotifications, e.SendModeratorNotifications) 14 | if err != nil { 15 | logger.Errorf("error updating email: %v", err) 16 | return errorInternal 17 | } 18 | 19 | return nil 20 | } 21 | 22 | func emailUpdateHandler(w http.ResponseWriter, r *http.Request) { 23 | type request struct { 24 | Email *email `json:"email"` 25 | } 26 | 27 | var x request 28 | if err := bodyUnmarshal(r, &x); err != nil { 29 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 30 | return 31 | } 32 | 33 | if err := emailUpdate(*x.Email); err != nil { 34 | bodyMarshal(w, response{"success": true, "message": err.Error()}) 35 | return 36 | } 37 | 38 | bodyMarshal(w, response{"success": true}) 39 | } 40 | -------------------------------------------------------------------------------- /api/go.mod: -------------------------------------------------------------------------------- 1 | module gitlab.com/commento/commento/api 2 | 3 | go 1.12 4 | 5 | require ( 6 | cloud.google.com/go v0.26.0 // indirect 7 | github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047 8 | github.com/disintegration/imaging v1.6.2 9 | github.com/golang/protobuf v1.1.0 // indirect 10 | github.com/gomodule/oauth1 v0.0.0-20181215000758-9a59ed3b0a84 11 | github.com/gorilla/context v1.1.1 // indirect 12 | github.com/gorilla/handlers v1.4.0 13 | github.com/gorilla/mux v1.6.2 14 | github.com/gorilla/websocket v1.4.2 15 | github.com/lib/pq v0.0.0-20180523175426-90697d60dd84 16 | github.com/lunny/html2md v0.0.0-20180317074532-13aaeeae9fb2 17 | github.com/microcosm-cc/bluemonday v1.0.0 18 | github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 19 | github.com/russross/blackfriday v1.5.1 20 | golang.org/x/crypto v0.0.0-20180808211826-de0752318171 21 | golang.org/x/net v0.0.0-20180811021610-c39426892332 22 | golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc 23 | google.golang.org/appengine v1.1.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /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/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/markdown.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/microcosm-cc/bluemonday" 5 | "github.com/russross/blackfriday" 6 | ) 7 | 8 | var policy *bluemonday.Policy 9 | var renderer blackfriday.Renderer 10 | var extensions int 11 | 12 | func markdownRendererCreate() error { 13 | policy = bluemonday.UGCPolicy() 14 | policy.AddTargetBlankToFullyQualifiedLinks(true) 15 | policy.RequireParseableURLs(true) 16 | policy.AllowURLSchemes("mailto", "http", "https") 17 | 18 | extensions = 0 19 | extensions |= blackfriday.EXTENSION_AUTOLINK 20 | extensions |= blackfriday.EXTENSION_STRIKETHROUGH 21 | 22 | htmlFlags := 0 23 | htmlFlags |= blackfriday.HTML_SKIP_HTML 24 | htmlFlags |= blackfriday.HTML_SKIP_IMAGES 25 | htmlFlags |= blackfriday.HTML_HREF_TARGET_BLANK 26 | 27 | renderer = blackfriday.HtmlRenderer(htmlFlags, "", "") 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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\nBar
", 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 | "": "", 24 | 25 | "**bold** *italics*": "bold italics
", 26 | 27 | "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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/oauth_github_redirect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func githubRedirectHandler(w http.ResponseWriter, r *http.Request) { 9 | if githubConfig == nil { 10 | logger.Errorf("github oauth access attempt without configuration") 11 | fmt.Fprintf(w, "error: this website has not configured github OAuth") 12 | return 13 | } 14 | 15 | commenterToken := r.FormValue("commenterToken") 16 | 17 | _, err := commenterGetByCommenterToken(commenterToken) 18 | if err != nil && err != errorNoSuchToken { 19 | fmt.Fprintf(w, "error: %s\n", err.Error()) 20 | return 21 | } 22 | 23 | url := githubConfig.AuthCodeURL(commenterToken) 24 | http.Redirect(w, r, url, http.StatusFound) 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/oauth_gitlab_redirect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func gitlabRedirectHandler(w http.ResponseWriter, r *http.Request) { 9 | if gitlabConfig == nil { 10 | logger.Errorf("gitlab oauth access attempt without configuration") 11 | fmt.Fprintf(w, "error: this website has not configured gitlab OAuth") 12 | return 13 | } 14 | 15 | commenterToken := r.FormValue("commenterToken") 16 | 17 | _, err := commenterGetByCommenterToken(commenterToken) 18 | if err != nil && err != errorNoSuchToken { 19 | fmt.Fprintf(w, "error: %s\n", err.Error()) 20 | return 21 | } 22 | 23 | url := gitlabConfig.AuthCodeURL(commenterToken) 24 | http.Redirect(w, r, url, http.StatusFound) 25 | } 26 | -------------------------------------------------------------------------------- /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/oauth_google_redirect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func googleRedirectHandler(w http.ResponseWriter, r *http.Request) { 9 | if googleConfig == nil { 10 | logger.Errorf("google oauth access attempt without configuration") 11 | fmt.Fprintf(w, "error: this website has not configured Google OAuth") 12 | return 13 | } 14 | 15 | commenterToken := r.FormValue("commenterToken") 16 | 17 | _, err := commenterGetByCommenterToken(commenterToken) 18 | if err != nil && err != errorNoSuchToken { 19 | fmt.Fprintf(w, "error: %s\n", err.Error()) 20 | return 21 | } 22 | 23 | url := googleConfig.AuthCodeURL(commenterToken) 24 | http.Redirect(w, r, url, http.StatusFound) 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/owner_self.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func ownerSelfHandler(w http.ResponseWriter, r *http.Request) { 8 | type request struct { 9 | OwnerToken *string `json:"ownerToken"` 10 | } 11 | 12 | var x request 13 | if err := bodyUnmarshal(r, &x); err != nil { 14 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 15 | return 16 | } 17 | 18 | o, err := ownerGetByOwnerToken(*x.OwnerToken) 19 | if err == errorNoSuchToken { 20 | bodyMarshal(w, response{"success": true, "loggedIn": false}) 21 | return 22 | } 23 | 24 | if err != nil { 25 | bodyMarshal(w, response{"success": false, "message": err.Error()}) 26 | return 27 | } 28 | 29 | bodyMarshal(w, response{"success": true, "loggedIn": true, "owner": o}) 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/page_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func pageGet(domain string, path string) (page, error) { 8 | // path can be empty 9 | if domain == "" { 10 | return page{}, errorMissingField 11 | } 12 | 13 | statement := ` 14 | SELECT isLocked, commentCount, stickyCommentHex, title 15 | FROM pages 16 | WHERE domain=$1 AND path=$2; 17 | ` 18 | row := db.QueryRow(statement, domain, path) 19 | 20 | p := page{Domain: domain, Path: path} 21 | if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex, &p.Title); err != nil { 22 | if err == sql.ErrNoRows { 23 | // If there haven't been any comments, there won't be a record for this 24 | // page. The sane thing to do is return defaults. 25 | // TODO: the defaults are hard-coded in two places: here and the schema 26 | p.IsLocked = false 27 | p.CommentCount = 0 28 | p.StickyCommentHex = "none" 29 | p.Title = "" 30 | } else { 31 | logger.Errorf("error scanning page: %v", err) 32 | return page{}, errorInternal 33 | } 34 | } 35 | 36 | return p, nil 37 | } 38 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/page_title.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import () 4 | 5 | func pageTitleUpdate(domain string, path string) (string, error) { 6 | title, err := htmlTitleGet("http://" + domain + path) 7 | if err != nil { 8 | // This could fail due to a variety of reasons that we can't control such 9 | // as the user's URL 404 or something, so let's not pollute the error log 10 | // with messages. Just use a sane title. Maybe we'll have the ability to 11 | // retry later. 12 | logger.Errorf("%v", err) 13 | title = domain 14 | } 15 | 16 | statement := ` 17 | UPDATE pages 18 | SET title = $3 19 | WHERE domain = $1 AND path = $2; 20 | ` 21 | _, err = db.Exec(statement, domain, path, title) 22 | if err != nil { 23 | logger.Errorf("cannot update pages table with title: %v", err) 24 | return "", err 25 | } 26 | 27 | return title, nil 28 | } 29 | -------------------------------------------------------------------------------- /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/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/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/router.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gorilla/handlers" 5 | "github.com/gorilla/mux" 6 | "net/http" 7 | "os" 8 | ) 9 | 10 | func routesServe() error { 11 | router := mux.NewRouter() 12 | 13 | subdir := pathStrip(os.Getenv("ORIGIN")) 14 | if subdir != "" { 15 | router = router.PathPrefix(subdir).Subrouter() 16 | } 17 | 18 | if err := apiRouterInit(router); err != nil { 19 | return err 20 | } 21 | 22 | if err := staticRouterInit(router); err != nil { 23 | return err 24 | } 25 | 26 | origins := handlers.AllowedOrigins([]string{"*"}) 27 | headers := handlers.AllowedHeaders([]string{"X-Requested-With"}) 28 | methods := handlers.AllowedMethods([]string{"GET", "POST"}) 29 | 30 | addrPort := os.Getenv("BIND_ADDRESS") + ":" + os.Getenv("PORT") 31 | 32 | logger.Infof("starting server on %s\n", addrPort) 33 | if err := http.ListenAndServe(addrPort, handlers.CORS(origins, headers, methods)(router)); err != nil { 34 | logger.Errorf("cannot start server: %v", err) 35 | return err 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /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/smtp_configure.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/smtp" 5 | "os" 6 | ) 7 | 8 | var smtpConfigured bool 9 | var smtpAuth smtp.Auth 10 | 11 | func smtpConfigure() error { 12 | username := os.Getenv("SMTP_USERNAME") 13 | password := os.Getenv("SMTP_PASSWORD") 14 | host := os.Getenv("SMTP_HOST") 15 | port := os.Getenv("SMTP_PORT") 16 | if host == "" || port == "" { 17 | logger.Warningf("smtp not configured, no emails will be sent") 18 | smtpConfigured = false 19 | return nil 20 | } 21 | 22 | if os.Getenv("SMTP_FROM_ADDRESS") == "" { 23 | logger.Errorf("COMMENTO_SMTP_FROM_ADDRESS not set") 24 | smtpConfigured = false 25 | return errorMissingSmtpAddress 26 | } 27 | 28 | logger.Infof("configuring smtp: %s", host) 29 | if username == "" || password == "" { 30 | logger.Warningf("no SMTP username/password set, Commento will assume they aren't required") 31 | } else { 32 | smtpAuth = smtp.PlainAuth("", username, password, host) 33 | } 34 | smtpConfigured = true 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /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/smtp_domain_export.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/smtp" 6 | "os" 7 | ) 8 | 9 | type domainExportPlugs struct { 10 | Origin string 11 | Domain string 12 | ExportHex string 13 | } 14 | 15 | func smtpDomainExport(to string, toName string, domain string, exportHex string) error { 16 | var header bytes.Buffer 17 | headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Commento Data Export"}) 18 | 19 | var body bytes.Buffer 20 | templates["domain-export"].Execute(&body, &domainExportPlugs{Origin: os.Getenv("ORIGIN"), ExportHex: exportHex}) 21 | 22 | err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body)) 23 | if err != nil { 24 | logger.Errorf("cannot send data export email: %v", err) 25 | return errorCannotSendEmail 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /api/smtp_domain_export_error.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/smtp" 6 | "os" 7 | ) 8 | 9 | type domainExportErrorPlugs struct { 10 | Origin string 11 | Domain string 12 | } 13 | 14 | func smtpDomainExportError(to string, toName string, domain string) error { 15 | var header bytes.Buffer 16 | headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Commento Data Export"}) 17 | 18 | var body bytes.Buffer 19 | templates["data-export-error"].Execute(&body, &domainExportPlugs{Origin: os.Getenv("ORIGIN")}) 20 | 21 | err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body)) 22 | if err != nil { 23 | logger.Errorf("cannot send data export error email: %v", err) 24 | return errorCannotSendEmail 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /api/smtp_owner_confirm_hex.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/smtp" 6 | "os" 7 | ) 8 | 9 | type ownerConfirmHexPlugs struct { 10 | Origin string 11 | ConfirmHex string 12 | } 13 | 14 | func smtpOwnerConfirmHex(to string, toName string, confirmHex string) error { 15 | var header bytes.Buffer 16 | headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Please confirm your email address"}) 17 | 18 | var body bytes.Buffer 19 | templates["confirm-hex"].Execute(&body, &ownerConfirmHexPlugs{Origin: os.Getenv("ORIGIN"), ConfirmHex: confirmHex}) 20 | 21 | err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body)) 22 | if err != nil { 23 | logger.Errorf("cannot send confirmation email: %v", err) 24 | return errorCannotSendEmail 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /api/smtp_reset_hex.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/smtp" 6 | "os" 7 | ) 8 | 9 | type resetHexPlugs struct { 10 | Origin string 11 | ResetHex string 12 | } 13 | 14 | func smtpResetHex(to string, toName string, resetHex string) error { 15 | var header bytes.Buffer 16 | headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"}) 17 | 18 | var body bytes.Buffer 19 | templates["reset-hex"].Execute(&body, &resetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex}) 20 | 21 | err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body)) 22 | if err != nil { 23 | logger.Errorf("cannot send reset email: %v", err) 24 | return errorCannotSendEmail 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /api/utils_html.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "golang.org/x/net/html" 5 | "net/http" 6 | ) 7 | 8 | func htmlTitleRecurse(h *html.Node) string { 9 | if h == nil || h.FirstChild == nil { 10 | return "" 11 | } 12 | 13 | if h.Type == html.ElementNode && h.Data == "title" { 14 | return h.FirstChild.Data 15 | } 16 | 17 | for c := h.FirstChild; c != nil; c = c.NextSibling { 18 | res := htmlTitleRecurse(c) 19 | if res != "" { 20 | return res 21 | } 22 | } 23 | 24 | return "" 25 | } 26 | 27 | func htmlTitleGet(url string) (string, error) { 28 | resp, err := http.Get(url) 29 | if err != nil { 30 | return "", err 31 | } 32 | defer resp.Body.Close() 33 | 34 | h, err := html.Parse(resp.Body) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | return htmlTitleRecurse(h), nil 40 | } 41 | -------------------------------------------------------------------------------- /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/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_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/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_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/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/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/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 | -------------------------------------------------------------------------------- /db/20180610215858-commenter-password.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE commenters 2 | ADD passwordHash TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /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/20180724125115-remove-config.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS config; 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /db/20181007231407-v1.1.4.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.2.0'; 3 | -------------------------------------------------------------------------------- /db/20181218183803-sticky-comments.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE pages 2 | ADD stickyCommentHex TEXT NOT NULL DEFAULT 'none'; 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/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/20190123002724-v1.4.2.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.4.2'; 3 | -------------------------------------------------------------------------------- /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/20190204180609-v1.5.0.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.5.0'; 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /db/20190218173502-v1.6.0.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.6.0'; 3 | -------------------------------------------------------------------------------- /db/20190218183556-v1.6.1.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fidiego/commento-heroku/bc92b9f4e51812b4efab5faf1349df09563f6f6e/db/20190218183556-v1.6.1.sql -------------------------------------------------------------------------------- /db/20190219001130-v1.6.2.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.6.0'; 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /db/20190501201032-v1.7.0.sql: -------------------------------------------------------------------------------- 1 | UPDATE config 2 | SET version = 'v1.7.0'; 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /db/20190508222848-reset-count.sql: -------------------------------------------------------------------------------- 1 | UPDATE pages 2 | SET commentCount = commentCount + 1; 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /db/20200730134007-comment-count-update.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 | RETURN NEW; 9 | END; 10 | $trigger$ LANGUAGE plpgsql; 11 | 12 | CREATE TRIGGER commentsDeleteTrigger AFTER DELETE ON comments 13 | FOR EACH ROW EXECUTE PROCEDURE commentsDeleteTriggerFunction(); 14 | 15 | -- fix any broken comment counts 16 | UPDATE pages SET commentCount = 0; 17 | UPDATE pages 18 | SET commentCount = subquery.commentCount 19 | FROM ( 20 | SELECT COUNT(commentHex) as commentCount, DOMAIN AS dmn, PATH AS pth 21 | FROM comments 22 | WHERE state = 'approved' 23 | GROUP BY (domain, path) 24 | ) as subquery 25 | WHERE domain = subquery.dmn AND path=subquery.pth; -------------------------------------------------------------------------------- /db/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 | DB_SRC_DIR = . 8 | DB_SRC_FILES = $(wildcard $(DB_SRC_DIR)/*.sql) 9 | DB_DEVEL_BUILD_DIR = $(DEVEL_BUILD_DIR)/db 10 | DB_DEVEL_BUILD_FILES = $(patsubst $(DB_SRC_DIR)/%, $(DB_DEVEL_BUILD_DIR)/%, $(DB_SRC_FILES)) 11 | DB_PROD_BUILD_DIR = $(PROD_BUILD_DIR)/db 12 | DB_PROD_BUILD_FILES = $(patsubst $(DB_SRC_DIR)/%, $(DB_PROD_BUILD_DIR)/%, $(DB_SRC_FILES)) 13 | 14 | devel: devel-db 15 | 16 | prod: prod-db 17 | 18 | clean: 19 | rm -rf $(BUILD_DIR) 20 | 21 | devel-db: $(DB_DEVEL_BUILD_FILES) 22 | 23 | $(DB_DEVEL_BUILD_FILES): $(DB_DEVEL_BUILD_DIR)/%.sql: $(DB_SRC_DIR)/%.sql 24 | cp $^ $@; 25 | 26 | prod-db: $(DB_PROD_BUILD_FILES) 27 | 28 | $(DB_PROD_BUILD_FILES): $(DB_PROD_BUILD_DIR)/%.sql: $(DB_SRC_DIR)/%.sql 29 | cp $^ $@; 30 | 31 | $(shell mkdir -p $(DB_DEVEL_BUILD_DIR) $(DB_PROD_BUILD_DIR)) 32 | -------------------------------------------------------------------------------- /db/new.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | timestamp=$(date +%Y%m%d%H%M%S) 4 | 5 | printf "rules:\n" 6 | printf " * use hyphens to separate words (not spaces, not underscores)\n" 7 | printf " * keep it as short as possible (add comments inside the file)\n" 8 | printf " * try to keep each migration idempotent (roughly, the order of application shouldn't matter)\n" 9 | printf "\n" 10 | printf "good example: 20180416164303-init-schema.sql\n\n" 11 | printf "filename: %s-" "${timestamp}" 12 | read filename 13 | 14 | filename="${timestamp}-${filename}" 15 | if [[ ! $filename =~ .sql$ ]]; then 16 | filename="${filename}.sql" 17 | fi 18 | 19 | touch "${filename}" 20 | echo "created ${filename}" 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | server: 5 | image: registry.gitlab.com/commento/commento 6 | ports: 7 | - 8080:8080 8 | environment: 9 | COMMENTO_ORIGIN: http://commento.example.com:8080 10 | COMMENTO_PORT: 8080 11 | COMMENTO_POSTGRES: postgres://postgres:postgres@db:5432/commento?sslmode=disable 12 | depends_on: 13 | - db 14 | networks: 15 | - db_network 16 | db: 17 | image: postgres 18 | environment: 19 | POSTGRES_DB: commento 20 | POSTGRES_USER: postgres 21 | POSTGRES_PASSWORD: postgres 22 | networks: 23 | - db_network 24 | volumes: 25 | - postgres_data_volume:/var/lib/postgresql/data 26 | 27 | networks: 28 | db_network: 29 | 30 | volumes: 31 | postgres_data_volume: 32 | -------------------------------------------------------------------------------- /etc/bsd-rc/commento: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # PROVIDE: commento 4 | # REQUIRE: LOGIN postgresql 5 | # KEYWORD: shutdown 6 | 7 | PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" 8 | 9 | . /etc/rc.subr 10 | 11 | desc="Commento daemon" 12 | name=commento 13 | rcvar=commento_enable 14 | 15 | load_rc_config $name 16 | 17 | : ${commento_enable:=NO} 18 | 19 | commento_env="COMMENTO_ORIGIN=https://commento.example.com \ 20 | COMMENTO_PORT=8080 \ 21 | COMMENTO_POSTGRES=postgres://commento:commento@db:5432/commento?sslmode=disable \ 22 | COMMENTO_STATIC=/usr/local/share/commento" 23 | commento_user=www 24 | 25 | command="/usr/local/bin/commento" 26 | command_args=" >> /var/log/commento/${name}.log 2>&1 &" 27 | 28 | run_rc_command "$1" 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "globals": { 6 | "$": true 7 | }, 8 | "rules": { 9 | "no-bitwise": 2, 10 | "camelcase": 2, 11 | "brace-style": ["error", "1tbs"], 12 | "curly": ["error", "all"], 13 | "eqeqeq": ["error", "smart"], 14 | "indent": ["error", 2], 15 | "no-use-before-define": [ 16 | 2, 17 | { 18 | "functions": false 19 | } 20 | ], 21 | "new-cap": 2, 22 | "no-caller": 2, 23 | "quotes": [ 24 | 2, 25 | "double" 26 | ], 27 | "no-unused-vars": 2, 28 | "strict": [ 29 | 2, 30 | "function" 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .sass-cache 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /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/confirm-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |