99 Commits

Author SHA1 Message Date
Adhityaa Chandrasekar
a4c5cc8b9e commento.js: show comment 404 only hash is an ID
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 15:28:24 +05:30
Adhityaa Chandrasekar
fc83eed221 comment_new.go: simplify state logic
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 13:43:58 +05:30
Adhityaa Chandrasekar
18612933f6 commento.js: show error when comment link is 404
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 13:16:36 +05:30
Adhityaa Chandrasekar
aaa44a0bee api: log comment deleter and deletion date
Signed-off-by: Adhityaa Chandrasekar <adtac@adtac.in>
2021-02-28 12:46:43 +05:30
Drew Foehn
84bfd64e32 Dockerfile: Added build arg, RELEASE.
This allows you to build a debug version of commento and deploy to docker. Useful when debugging a containered version of commento.
2021-02-02 11:30:16 +05:30
Aaron
800902640b Update base images in Dockerfile 2021-02-02 11:26:57 +05:30
Souradip Mookerjee
5390c6f81c Fix twitter profile photo import bug 2021-01-16 00:26:17 +00:00
pawurb
326601394a commento.js: add lazy loading to avatar images 2020-05-22 16:21:49 -04:00
Wouter Groeneveld
3c3cf08656 utils.js: use global.origin in cookieSet() 2020-05-09 23:53:42 +00:00
Adhityaa Chandrasekar
e44ae1ce9d dashboard-main.scss: remove height from action buttons 2020-04-11 19:48:59 -04:00
Adhityaa Chandrasekar
025bb10c0b .gitlab-ci.yml: fix postgres to 9.6 2020-04-10 17:13:05 -04:00
Adhityaa Chandrasekar
9a4563fdb3 db: maintain 9.6 compatibility
I know it's generally frowned upon to make edits to existing migrations,
but this should be a transparent change that makes absolutely no
difference to existing users with the migration already applied.
However, PostgreSQL 9.6 users (still the default on Debian Stretch)
stand to gain a lot from this simple change.
2020-04-10 17:10:56 -04:00
Adhityaa Chandrasekar
daae592b5d commento.js: use a event listener in CSS overrides
Closes https://gitlab.com/commento/commento/-/issues/302
2020-03-31 07:00:46 -04:00
Adhityaa Chandrasekar
fbc98bce08 commento.js: remove stray console.log 2020-03-31 06:47:03 -04:00
Adhityaa Chandrasekar
36204ff81b constants.go: set version at compile time 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
f09a619d41 Dockerfile: use go 1.14 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
f8e6cc78dc .gitlab-ci.yml: use 1.14 in go test 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
3b2ed644a3 .gitlab-ci.yml: use 1.14 in go fmt 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
49a8669970 .gitlab-ci.yml: remove aws-upload-tags stage 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
7f97686b8f dashboard.html: use a generic commento import placeholder 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
af2626c443 domain_export_download.go: include json in filename 2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
20027d0efe domain_export_download.go: do not add gzip encoding header
Adding a gzip encoding header would cause the browser to decode the data
on the client side. When re-importing the data, a plain JSON is imported
instead of a GZIP version.
2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
885b4c6689 api: use commentoExportV1 struct 2020-03-31 06:39:52 -04:00
igolaizola
0d929595cc api: import from commento export format
JSON data can be imported to restore previously exported data
or to migrate data from another self-hosted commento instance.

Closes https://gitlab.com/commento/commento/issues/239
2020-03-31 06:39:52 -04:00
Adhityaa Chandrasekar
998bc43d8c settings.html: remove Stripe JavaScript
Closes https://gitlab.com/commento/commento/-/issues/272
2020-03-19 06:47:05 -04:00
Adhityaa Chandrasekar
881bd54c4f dashboard.html: include configuredOauths in warning logic 2020-03-19 06:47:05 -04:00
Adhityaa Chandrasekar
7f64614f60 commento.js: default to anonymous if only option
Closes https://gitlab.com/commento/commento/-/issues/285
2020-03-19 06:47:05 -04:00
atagulalan
d077241f09 Fixed Github OAuth name error 2020-03-19 06:47:05 -04:00
matclab
b57b6bcc12 oauth config: Use GITLAB_URL env variable
In order to use oauth from gitlab CE instance one has to be
able to provide the URL of the instance.

closes #209 (https://gitlab.com/commento/commento/issues/209)
2020-03-19 06:47:05 -04:00
Frieder Griesshammer
07cfcc9c17 dashboard.js: add defer to the script tag in installation guide on page
This matches the instructions with the documentation

[amended by @adtac]: Special thanks to @jeffreywyman for their work!
2020-03-19 06:45:42 -04:00
evalphobia
7f323b5abe oauth_twitter_callback.go: fix avatar icon and account url for Twitter 2020-03-19 05:04:29 -04:00
Yann Pringault
b14de2eb53 frontend: upgrade node-sass to support Node 13 2020-03-03 18:42:43 +01:00
Adhityaa Chandrasekar
55a3d1fd89 api: go fmt 2020-02-13 20:15:05 -05:00
Adhityaa Chandrasekar
986b05f89a api, frontend: restrict profile updates to commento provider 2020-02-13 20:11:47 -05:00
Adhityaa Chandrasekar
b7c214e910 commenter_update.go: parse empty links as undefined 2020-02-13 20:01:44 -05:00
Adhityaa Chandrasekar
ea3419e8b4 commenter_photo.go: resize images to 38px 2020-02-13 20:00:42 -05:00
Adhityaa Chandrasekar
b29147a95b commenter_update.go: make link optional 2020-02-13 19:38:47 -05:00
Adhityaa Chandrasekar
b3f2cf3064 database_connect.go: redact password before log message 2020-02-13 19:12:54 -05:00
Ricky Panzer
bbea9df8b8 commenter_login.go: include email in response
Fixes https://gitlab.com/commento/commento/issues/271
2020-02-13 18:47:58 -05:00
Ricky Panzer
44dd4fa00c commento.js: re-render comments after login
Re-render comments after login to accurately paint votes and allow
upvoting without reprompting user to login, even though they're logged
in.

Closes https://gitlab.com/commento/commento/issues/170
2020-02-13 18:47:58 -05:00
Mariusz Gronczewski
2006b02f59 smtp_configure.go: disable smtp auth when unset 2020-02-13 18:47:58 -05:00
PIE
c040f95e25 Dockerfile: add arm64 support 2020-02-13 18:47:58 -05:00
David Planella
90f39499a1 commento.js: define new thresholds for time ago descriptions 2020-02-13 18:33:18 +00:00
Kaspars
6978171885 fix errorf missing argument 2020-02-03 22:17:41 +02:00
Adhityaa Chandrasekar
166599a2c8 count.js: support data-page-id paths
Closes https://gitlab.com/commento/commento/issues/255
2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
15022ba3a0 count.js: replace with script-based data-custom-text 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
dc24a40a37 api, frontend: add account deletion
Closes https://gitlab.com/commento/commento/issues/120
2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
80dc91ca05 domain_delete.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
f6d6a1f77f owner_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
d6e7507b2c api: sql statements: replace spaces with tabs 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
e7a5e01379 comment_vote.go: clean up SQL 2020-01-02 14:19:44 -08:00
Dave
e0504a0c88 comment_vote.go: fix logger msg typo erorr->error
Closes https://gitlab.com/commento/commento/merge_requests/124
2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
6cfa9922de email_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
72a3f87c28 domain_get.go, domain_list.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
c94e5ca41f comment_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
a05d8eeb07 commenter_get.go: clean up SQL 2020-01-02 14:19:44 -08:00
Adhityaa Chandrasekar
024859ff45 utils_sql.go: add sqlScanner interface 2020-01-02 14:19:44 -08:00
Dave
042fda0e8c commenter_get.go: single query commenterGetByCommenterToken
Closes https://gitlab.com/commento/commento/merge_requests/125
2020-01-02 14:19:41 -08:00
Adhityaa Chandrasekar
885c4dea9f dashboard.html: link to frontend configuration 2019-12-15 00:32:57 -08:00
Adhityaa Chandrasekar
0b1e2002d0 commento.js: add data-page-id option 2019-12-15 00:32:57 -08:00
Adhityaa Chandrasekar
2f9368a275 commenter_update.go: allow editing link, photo 2019-12-04 22:48:44 -08:00
Adhityaa Chandrasekar
2a11149034 .gitlab-ci.yml: remove dco 2019-12-04 22:12:31 -08:00
Adhityaa Chandrasekar
918a691ba3 api, frontend: allow editing profile information
Closes https://gitlab.com/commento/commento/issues/235
2019-12-04 22:12:29 -08:00
Adhityaa Chandrasekar
3e1576d494 api, frontend, db: add comment sorting
Closes https://gitlab.com/commento/commento/issues/215
2019-12-04 18:50:50 -08:00
Adhityaa Chandrasekar
3101af8a5c utils_sanitise.go: strip protocol before trailer
Fixes https://gitlab.com/commento/commento/issues/176
2019-11-21 01:15:08 -08:00
Adhityaa Chandrasekar
162b11bd7a api: do not batch email notifications
Closes https://gitlab.com/commento/commento/issues/234
2019-10-25 01:10:44 -07:00
Adhityaa Chandrasekar
0e5bcb8a79 commento.js: fix edit failure after new comment
Since the comment's data is not in commentsMap, the edit dialog does not
present the user with any text to edit.
2019-10-06 17:00:39 -07:00
Adhityaa Chandrasekar
b2b8d1b5d0 commento.js: hide deleted if children are deleted
This also adds a data-hide-deleted data tag that allows you to hide
deleted comments even if they have undeleted children.
2019-10-06 16:55:09 -07:00
Adhityaa Chandrasekar
c2bda4abc6 commento.js: confirm before deleting 2019-09-13 18:17:40 -07:00
Adhityaa Chandrasekar
ee7875cc1e commento.js: don't recursively delete comments 2019-09-13 18:13:38 -07:00
Adhityaa Chandrasekar
3ef4a79547 commento.js: use value instead of innerText for textarea 2019-09-13 17:26:53 -07:00
Adhityaa Chandrasekar
a9a1dc6376 frontend: migrate to gulp 4 2019-09-13 17:14:43 -07:00
Adhityaa Chandrasekar
b682fd14fa commenter_update.go: run go fmt 2019-09-13 16:15:00 -07:00
Adhityaa Chandrasekar
b278522e35 commento.js: preserve whitespace in edits 2019-09-13 16:15:00 -07:00
Adhityaa Chandrasekar
982a574512 email_notification_new.go: do not return on pageTitleUpdate errors 2019-09-13 16:15:00 -07:00
Adhityaa Chandrasekar
52f8df5183 config.go: parse config file before default
Closes https://gitlab.com/commento/commento/issues/187
2019-09-13 16:14:59 -07:00
Adhityaa Chandrasekar
9538c9036e api: update commenter information on new login 2019-08-21 21:05:17 -07:00
Adhityaa Chandrasekar
696361df4a reset.js: remove self.close call 2019-07-25 18:25:08 -07:00
Enrico Testori
4a8e90bd43 db: decrease comment count when a comment is deleted
Update commentsDeleteTriggerFunction in order to decrease the
counter before deleting nested comments.

Closes https://gitlab.com/commento/commento/issues/157
2019-07-25 18:25:08 -07:00
Adhityaa Chandrasekar
62340eb9c6 comment_new.go: create page before inserting comment
Fixes https://gitlab.com/commento/commento/issues/173
2019-07-25 18:25:08 -07:00
pawurb
ff04981cf5 count.js: allow customizing no comments text
Optional 'data-hide-no-comments-count' allows to hide comments count
when there are no comments.
2019-06-23 23:06:29 +02:00
Adhityaa Chandrasekar
f37e26bfc2 .gitlab-ci.yml: use go 1.12 2019-06-06 01:56:28 -07:00
Adhityaa Chandrasekar
73234832b6 api: vendor deps before tests 2019-06-06 01:44:17 -07:00
Adhityaa Chandrasekar
85456a019e api,frontend: add commenter password resets 2019-06-06 01:33:31 -07:00
Adhityaa Chandrasekar
36fea6e95b commento.js: make login box more intuitive 2019-06-05 22:15:12 -07:00
Adhityaa Chandrasekar
cc00387136 commento.js: preserve login button on failed oauth logins 2019-06-05 21:58:48 -07:00
Adhityaa Chandrasekar
cd8a2bbf99 Dockerfile: use minor point release containers 2019-06-05 21:43:13 -07:00
Adhityaa Chandrasekar
bdda465f33 api: use go mod 2019-06-05 21:43:12 -07:00
WGH
6e22d10b02 api/Makefile: remove -i flag to fix cross-compilation
When -i (install) flag is passed to `go build`, it attempts
to install dependencies of the target.

This will usually fail during cross-compilation due to GOROOT being
not writeable to non-root users:

    runtime/internal/sys
    go build runtime/internal/sys: mkdir /usr/lib/go/pkg/linux_arm: permission denied

Installing dependencies makes no sense in recent Go versions,
because compilation is cached anyway (see `go help cache`).
2019-06-05 20:45:03 -07:00
Adhityaa Chandrasekar
48bbceabc8 comment_get.go: run go fmt 2019-05-15 11:46:11 -07:00
Adhityaa Chandrasekar
cc1dfee017 api, frontend: add comment editing 2019-05-15 10:46:51 -07:00
Adhityaa Chandrasekar
ce19cb8842 comment_list.go: do not include markdown in most responses 2019-05-15 10:45:01 -07:00
Adhityaa Chandrasekar
b816c09802 commento-options.scss: optimise remove SVG slightly 2019-05-15 09:41:48 -07:00
Adhityaa Chandrasekar
8dbeecf71e commento.js: reset selfHex and state when logging out 2019-05-15 09:13:56 -07:00
Adhityaa Chandrasekar
5faa727ef8 api: add option to delete own comments 2019-05-15 09:13:39 -07:00
Adhityaa Chandrasekar
5228ff671a README.md: overhaul 2019-05-03 20:04:00 -04:00
Adhityaa Chandrasekar
038b6780d2 commento-common.scss: preserve newline in code blocks
Closes https://gitlab.com/commento/commento/issues/164
2019-05-03 19:50:27 -04:00
Adhityaa Chandrasekar
0a793b90cc common-main.scss: do not apply colours to input 2019-05-03 19:45:44 -04:00
Adhityaa Chandrasekar
ab312bfe20 commento.scss: have root-font take precedence 2019-05-03 19:38:23 -04:00
109 changed files with 3633 additions and 2207 deletions

View File

@@ -1,24 +1,11 @@
stages:
- check-dco
- go-fmt
- go-test
- build-src
- aws-upload-tags
- build-docker
- docker-registry-master
- docker-registry-tags
check-dco:
stage: check-dco
image: debian:buster
except:
- master
- tags
script:
- apt update
- apt install -y curl git jq
- bash ./scripts/check-dco
build-src:
stage: build-src
image: debian:buster
@@ -34,25 +21,6 @@ build-src:
- make devel
- make prod
aws-upload-tags:
stage: aws-upload-tags
image: debian:buster
environment: aws-upload-tags
variables:
AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
only:
- tags
before_script:
- bash $CI_PROJECT_DIR/scripts/gitlab-ci-build-prescript
script:
- export GOPATH=/go
- export PATH=$PATH:/go/bin
- cd /go/src/$CI_PROJECT_NAME
- make prod
- cd build/prod && tar -zcvf /commento-linux-amd64-$(git describe --tags).tar.gz .
- aws s3 cp /commento-linux-amd64-$(git describe --tags).tar.gz s3://commento-release/
build-docker:
stage: build-docker
image: docker:stable
@@ -66,9 +34,9 @@ build-docker:
go-test:
stage: go-test
image: golang:1.10.2
image: golang:1.14
services:
- postgres:latest
- postgres:9.6
variables:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@@ -81,7 +49,6 @@ go-test:
- mkdir -p /go/src /go/bin /go/pkg
- export GOPATH=/go
- export PATH=$PATH:/go/bin
- curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
- ln -s $CI_PROJECT_DIR /go/src/$CI_PROJECT_NAME
script:
- cd /go/src/$CI_PROJECT_NAME
@@ -89,7 +56,7 @@ go-test:
go-fmt:
stage: go-fmt
image: golang:1.10.2
image: golang:1.14
except:
- master
- tags

View File

@@ -1,66 +1,53 @@
# backend build (api server)
FROM golang:1.10.2-alpine AS api-build
FROM golang:1.15-alpine AS api-build
RUN apk add --no-cache --update bash dep make git curl g++
COPY ./api /go/src/commento/api
ARG RELEASE=prod
COPY ./api /go/src/commento/api/
WORKDIR /go/src/commento/api
RUN apk update && apk add bash make git curl
RUN curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
RUN make prod -j$(($(nproc) + 1))
RUN make ${RELEASE} -j$(($(nproc) + 1))
# frontend build (html, js, css, images)
FROM node:10.3.0-alpine AS frontend-build
FROM node:12-alpine AS frontend-build
RUN apk add --no-cache --update bash make python2 g++
COPY ./frontend /commento/frontend/
ARG RELEASE=prod
COPY ./frontend /commento/frontend
WORKDIR /commento/frontend/
RUN apk update && apk add bash make
RUN npm install -g html-minifier@3.5.7 uglify-js@3.4.1 sass@1.5.1
RUN make prod -j$(($(nproc) + 1))
RUN make ${RELEASE} -j$(($(nproc) + 1))
# templates build
FROM alpine:3.7 AS templates-build
# templates and db build
FROM alpine:3.13 AS templates-db-build
RUN apk add --no-cache --update bash make
ARG RELEASE=prod
COPY ./templates /commento/templates
WORKDIR /commento/templates
RUN apk update && apk add bash make
RUN make prod -j$(($(nproc) + 1))
# db build
FROM alpine:3.7 AS db-build
RUN make ${RELEASE} -j$(($(nproc) + 1))
COPY ./db /commento/db
WORKDIR /commento/db
RUN apk update && apk add bash make
RUN make prod -j$(($(nproc) + 1))
RUN make ${RELEASE} -j$(($(nproc) + 1))
# final image
FROM alpine:3.7
FROM alpine:3.13
RUN apk add --no-cache --update ca-certificates
COPY --from=api-build /go/src/commento/api/build/prod/commento /commento/commento
COPY --from=frontend-build /commento/frontend/build/prod/*.html /commento/
COPY --from=frontend-build /commento/frontend/build/prod/css/*.css /commento/css/
COPY --from=frontend-build /commento/frontend/build/prod/js/*.js /commento/js/
COPY --from=frontend-build /commento/frontend/build/prod/images/* /commento/images/
COPY --from=frontend-build /commento/frontend/build/prod/fonts/* /commento/fonts/
COPY --from=templates-build /commento/templates/build/prod/templates/ /commento/templates/
COPY --from=db-build /commento/db/build/prod/db/ /commento/db/
ARG RELEASE=prod
RUN apk update && apk add ca-certificates --no-cache
COPY --from=api-build /go/src/commento/api/build/${RELEASE}/commento /commento/commento
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/js /commento/js
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/css /commento/css
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/images /commento/images
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/fonts /commento/fonts
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/*.html /commento/
COPY --from=templates-db-build /commento/templates/build/${RELEASE}/templates /commento/templates/
COPY --from=templates-db-build /commento/db/build/${RELEASE}/db /commento/db/
EXPOSE 8080
WORKDIR /commento/
ENV COMMENTO_BIND_ADDRESS="0.0.0.0"
ENTRYPOINT ["/commento/commento"]

View File

@@ -1,90 +1,21 @@
<p align="center">
<a href="https://commento.io"><img src="https://user-images.githubusercontent.com/7521600/33375172-14b21f68-d52f-11e7-9b30-477682bccf8f.png" width=300></a>
</p>
### Commento
<p align="center"><b>A bloat-free and privacy-focused discussion platform.</b></p>
##### [Homepage](https://commento.io) &nbsp;&ndash;&nbsp; [Demo](https://demo.commento.io) &nbsp;&ndash;&nbsp; [Documentation](https://docs.commento.io) &nbsp;&ndash;&nbsp; [Contributing](https://docs.commento.io/contributing/) &nbsp;&ndash;&nbsp; [#commento on Freenode](http://webchat.freenode.net/?channels=%23commento)
### What is Commento?
Commento is a platform that you can embed in your website to allow your readers to add comments. It's reasonably fast lightweight. Supports markdown, import from Disqus, voting, automated spam detection, moderation tools, sticky comments, thread locking, OAuth login, single sign-on, and email notifications.
Commento allows you to foster discussion on your website &ndash; if you have a blog, you can embed Commento if you want your readers to add comments. It's fast and bloat-free, has a modern interface, and is reasonably secure. Unlike most alternatives, Commento is lightweight and privacy-focused; I'll never sell your data, show ads, embed third-party tracking scripts, or inject affiliate links.
###### How is this different from Disqus, Facebook Comments, and the rest?
### Frequently Asked Questions
Most other products in this space do not respect your privacy; showing ads is their primary business model and that nearly always comes at the users' cost. Commento has no ads; you're the customer, not the product. While Commento is [free software](https://www.gnu.org/philosophy/free-sw.en.html), in order to keep the service sustainable, the [hosted cloud version](https://commento.io) is not offered free of cost. Commento is also orders of magnitude lighter than alternatives.
**I don't want to install and manage Commento on a server.**
You can use [Commento.io](https://commento.io), the cloud version of Commento, where I do the server hosting, updates, and security and performance tuning for you. To make the hosted service self-sustainable, it is not free. You may choose the plan that best matches your financial situation and needs &ndash; all plans have all features.
###### Why should I care about my readers' privacy?
**What features does Commento have?**
Commento comes with a lot of useful features out-of-the-box: rich text support, upvotes and downvotes, automatic spam detection, moderation tools, sticky comments, thread locking, OAuth login, email notifications, and more!
For starters, your readers value their privacy. Not caring about them is disrespectful and you will end up alienating your audience; they won't come back. Disqus still isn't GDPR-compliant (according to their <a href="https://help.disqus.com/terms-and-policies/privacy-faq" title="At the time of writing (28 December 2018)" rel="nofollow">privacy policy</a>). Disqus adds megabytes to your page size; what happens when a random third-party script that is injected into your website turns malicious?
**What does Commento look like? Do you have a demo?**
Check out [demo.commento.io](https://demo.commento.io) to play around with a live demo of Commento.
#### Installation
**How is Commento different from Disqus, Facebook Comments, and the rest?**
Most other products in this space do not respect your privacy; showing adverts is their primary business model and that nearly always comes at the users' cost. There is no free lunch. Commento is also orders of magnitude lighter than alternatives &ndash; while Disqus and Facebook take megabytes of download to load, Commento is just 11 kB.
Read the [documentation to get started](https://docs.commento.io/installation/).
**Is Commento free software?**
Yes. Commento is made [freely available](https://gitlab.com/commento/commento) under the [MIT license](https://gitlab.com/commento/commento/blob/master/LICENSE). And it will always stay that way.
#### Contributing
**Disqus has a free plan. Why is the [cloud version](https://commento.io) not free of cost?**
When I say Commento is free, I mean [free as in freedom](https://www.gnu.org/philosophy/free-sw.en.html). The cloud version is not offered free of cost because servers cost money and offering the service for free would not be sustainable. Unlike most alternatives, Commento does not operate on adverts and shady tactics; you're the customer, not the product.
**I have nothing to hide. Why should I care about my privacy?**
The thing about privacy is that once you give up control over your information, you can't get it back. You may be fine with having your personal information sold to unknown third-parties today, but when your insurance company uses this information against you tomorrow, you'll regret it. And you'll have no recourse to correct this. Read [this Wikipedia article](https://en.wikipedia.org/wiki/Nothing_to_hide_argument) for more information.
<div><p style="margin: 0px 0px"><b>As a blog owner, why should I worry about my readers' privacy?</b><br>
Good question. For starters, your readers value their privacy. Not caring about them is disrespectful and you will end up alienating your audience; they won't come back. But even if you ignore this, you have bigger questions to answer:</p>
<ul>
<li><b>Legality</b>: Did you know that Disqus still isn't GDPR-compliant (according to their <a href="https://help.disqus.com/terms-and-policies/privacy-faq" title="At the time of writing (28 December 2018)" rel="nofollow">privacy policy</a>)?</li>
<li><b>Security</b>: What happens when a random third-party script is injected into your website?</li>
<li><b>Performance</b>: Did you know that half a second increase in page load time results in a 20% decrease in engagement and site traffic?</li>
<li><b>Ownership</b>: Who owns the content when your readers create comments?</li>
</ul></div>
**Who's behind this? Are you an evil corporation?**
My name is <a href="https://adtac.in">Adhityaa</a>, and I created the project. As someone who's still a student, I promise you I'm neither evil nor a corporation. But I'm not the only one &ndash; dozens of people have contributed to the project and Commento would not exist without these wonderful people.
**Okay, how do I get started?**
Glad you asked! You have two options &ndash; self-hosting Commento on your own server or using the [cloud version](https://commento.io). Start [from here](https://docs.commento.io/getting-started/) to decide which option is right for you and proceed from there.
### Installation
See our [documentation on how to install Commento](https://docs.commento.io/installation/) to get started.
### Contributing
Commento is possible only because of its community. If this is your first contribution to Commento, please go through the [documentation](https://docs.commento.io/contributing/) before you begin.
Help will always be given to those who ask for it. We use IRC for chat to collaborate with other developers. You're invited to [hang out with us](https://irc.commento.io) in the `#commento-dev` channel on freenode if you want to contribute to Commento!
### Sponsors
Commento development is partially sponsored by [Mozilla](https://mozilla.org) and [DigitalOcean](https://www.digitalocean.com/) independently.
<p align="center">
<a href="https://www.mozilla.org/en-US/"><img src="https://user-images.githubusercontent.com/7521600/32265838-d05b2d08-bf0a-11e7-92e1-2cb183eae616.png" title="Mozilla" height="40"></a>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
<a href="https://www.digitalocean.com"><img src="https://user-images.githubusercontent.com/7521600/32265839-d093c7da-bf0a-11e7-8d99-96a940041d06.png" title="DigitalOcean" height="40"></a>
</p>
### License
```
Copyright 2018 Commento, Inc.
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.
```
If this is your first contribution to Commento, please go through the [contribution guidelines](https://docs.commento.io/contributing/) before you begin. If you have any questions, join [#commento on Freenode](http://webchat.freenode.net/?channels=%23commento).

181
api/Gopkg.lock generated
View File

@@ -1,181 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
digest = "1:5c3894b2aa4d6bead0ceeea6831b305d62879c871780e7b76296ded1b004bc57"
name = "cloud.google.com/go"
packages = ["compute/metadata"]
pruneopts = "UT"
revision = "64a2037ec6be8a4b0c1d1f706ed35b428b989239"
version = "v0.26.0"
[[projects]]
branch = "master"
digest = "1:9769b231d8f5ff406a012aa7f293e45ed69d11617832a1c3c7b8c6ce1558a2a1"
name = "github.com/adtac/go-akismet"
packages = ["akismet"]
pruneopts = "UT"
revision = "0ca9e1023047c869ecd4bd3c20780511597a4a77"
[[projects]]
digest = "1:15042ad3498153684d09f393bbaec6b216c8eec6d61f63dff711de7d64ed8861"
name = "github.com/golang/protobuf"
packages = ["proto"]
pruneopts = "UT"
revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
version = "v1.1.0"
[[projects]]
branch = "master"
digest = "1:d03d0fae6a7a80e89c540787a69ab6e0d3b773fdb3303c0b3d96a15490c6ef32"
name = "github.com/gomodule/oauth1"
packages = ["oauth"]
pruneopts = "UT"
revision = "9a59ed3b0a84f454c260f2f8f82918223fc5630f"
[[projects]]
digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1"
name = "github.com/gorilla/context"
packages = ["."]
pruneopts = "UT"
revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42"
version = "v1.1.1"
[[projects]]
digest = "1:664d37ea261f0fc73dd17f4a1f5f46d01fbb0b0d75f6375af064824424109b7d"
name = "github.com/gorilla/handlers"
packages = ["."]
pruneopts = "UT"
revision = "7e0847f9db758cdebd26c149d0ae9d5d0b9c98ce"
version = "v1.4.0"
[[projects]]
digest = "1:e73f5b0152105f18bc131fba127d9949305c8693f8a762588a82a48f61756f5f"
name = "github.com/gorilla/mux"
packages = ["."]
pruneopts = "UT"
revision = "e3702bed27f0d39777b0b37b664b6280e8ef8fbf"
version = "v1.6.2"
[[projects]]
digest = "1:37ce7d7d80531b227023331002c0d42b4b4b291a96798c82a049d03a54ba79e4"
name = "github.com/lib/pq"
packages = [
".",
"oid",
]
pruneopts = "UT"
revision = "90697d60dd844d5ef6ff15135d0203f65d2f53b8"
[[projects]]
digest = "1:9fb8ccf24ca918be80e6129761cf232de0c142537f8d9eeb7a3a779a7f38fdd4"
name = "github.com/lunny/html2md"
packages = ["."]
pruneopts = "UT"
revision = "13aaeeae9fb293668db3ef1e145064684735f3ce"
[[projects]]
digest = "1:a1f5a38c6c82d8f1e8a7b9fb9ea8b125b17188cdfb38f2cd08055ff9b51f5ec4"
name = "github.com/microcosm-cc/bluemonday"
packages = ["."]
pruneopts = "UT"
revision = "dafebb5b6ff2861a0d69af64991e10866c19be85"
version = "v1.0.0"
[[projects]]
digest = "1:5b3b29ce0e569f62935d9541dff2e16cc09df981ebde48e82259076a73a3d0c7"
name = "github.com/op/go-logging"
packages = ["."]
pruneopts = "UT"
revision = "b2cb9fa56473e98db8caba80237377e83fe44db5"
version = "v1"
[[projects]]
digest = "1:8bc629776d035c003c7814d4369521afe67fdb8efc4b5f66540d29343b98cf23"
name = "github.com/russross/blackfriday"
packages = ["."]
pruneopts = "UT"
revision = "55d61fa8aa702f59229e6cff85793c22e580eaf5"
version = "v1.5.1"
[[projects]]
branch = "master"
digest = "1:1ecf2a49df33be51e757d0033d5d51d5f784f35f68e5a38f797b2d3f03357d71"
name = "golang.org/x/crypto"
packages = [
"bcrypt",
"blowfish",
]
pruneopts = "UT"
revision = "de0752318171da717af4ce24d0a2e8626afaeb11"
[[projects]]
branch = "master"
digest = "1:aa58645c149c9c3b62dc7ff51460602a88fc7b887633f2546fcdde27c91e6f03"
name = "golang.org/x/net"
packages = [
"context",
"context/ctxhttp",
"html",
"html/atom",
]
pruneopts = "UT"
revision = "c39426892332e1bb5ec0a434a079bf82f5d30c54"
[[projects]]
branch = "master"
digest = "1:341ceeee37101c62dae441691406bf4ecc71bbeb7b424417879fe88d9f88f487"
name = "golang.org/x/oauth2"
packages = [
".",
"github",
"gitlab",
"google",
"internal",
"jws",
"jwt",
]
pruneopts = "UT"
revision = "3d292e4d0cdc3a0113e6d207bb137145ef1de42f"
[[projects]]
digest = "1:c8907869850adaa8bd7631887948d0684f3787d0912f1c01ab72581a6c34432e"
name = "google.golang.org/appengine"
packages = [
".",
"internal",
"internal/app_identity",
"internal/base",
"internal/datastore",
"internal/log",
"internal/modules",
"internal/remote_api",
"internal/urlfetch",
"urlfetch",
]
pruneopts = "UT"
revision = "b1f26356af11148e710935ed1ac8a7f5702c7612"
version = "v1.1.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = [
"github.com/adtac/go-akismet/akismet",
"github.com/gomodule/oauth1/oauth",
"github.com/gorilla/handlers",
"github.com/gorilla/mux",
"github.com/lib/pq",
"github.com/lunny/html2md",
"github.com/microcosm-cc/bluemonday",
"github.com/op/go-logging",
"github.com/russross/blackfriday",
"golang.org/x/crypto/bcrypt",
"golang.org/x/net/html",
"golang.org/x/oauth2",
"golang.org/x/oauth2/github",
"golang.org/x/oauth2/gitlab",
"golang.org/x/oauth2/google",
]
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -1,45 +0,0 @@
[[constraint]]
name = "github.com/gorilla/handlers"
version = "1.4.0"
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.6.2"
[[constraint]]
# unfortunately, lib/pq doesn't have semver-ed releases yet
# TODO: don't use revisions, use a proper version once this is solved:
# https://github.com/lib/pq/issues/637
name = "github.com/lib/pq"
revision = "90697d60dd844d5ef6ff15135d0203f65d2f53b8"
[[constraint]]
# html2md doesn't have semver-ed releases yet either
# TODO: use a version once this is solved:
# https://github.com/lunny/html2md/issues/8
name = "github.com/lunny/html2md"
revision = "13aaeeae9fb293668db3ef1e145064684735f3ce"
[[constraint]]
name = "github.com/microcosm-cc/bluemonday"
version = "1.0.0"
[[constraint]]
name = "github.com/op/go-logging"
version = "1.0.0"
[[constraint]]
name = "golang.org/x/crypto"
branch = "master"
[[constraint]]
name = "golang.org/x/oauth2"
branch = "master"
[[constraint]]
name = "github.com/russross/blackfriday"
version = "1.5.1"
[prune]
go-tests = true
unused-packages = true

View File

@@ -25,15 +25,15 @@ clean:
# later down the line).
devel-go:
dep ensure
go build -i -v -o $(GO_DEVEL_BUILD_BINARY)
GO111MODULE=on go mod vendor
GO111MODULE=on go build -mod=vendor -v -o $(GO_DEVEL_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)"
prod-go:
dep ensure
go build -i -v -o $(GO_PROD_BUILD_BINARY)
GO111MODULE=on go mod vendor
GO111MODULE=on go build -mod=vendor -v -o $(GO_PROD_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)"
test-go:
dep ensure
GO111MODULE=on go mod vendor
go test -v .
$(shell mkdir -p $(GO_DEVEL_BUILD_DIR) $(GO_PROD_BUILD_DIR))

View File

@@ -16,4 +16,5 @@ type comment struct {
State string `json:"state,omitempty"`
CreationDate time.Time `json:"creationDate"`
Direction int `json:"direction"`
Deleted bool `json:"deleted"`
}

View File

@@ -11,7 +11,7 @@ func commentApprove(commentHex string) error {
statement := `
UPDATE comments
SET state = 'approved'
SET state = 'approved'
WHERE commentHex = $1;
`

View File

@@ -20,8 +20,8 @@ func TestCommentCountBasics(t *testing.T) {
return
}
if counts["/path.html"] != 2 {
t.Errorf("expected count=2 got count=%d", counts["/path.html"])
if counts["/path.html"] != 3 {
t.Errorf("expected count=3 got count=%d", counts["/path.html"])
return
}
}

View File

@@ -2,18 +2,26 @@ package main
import (
"net/http"
"time"
)
func commentDelete(commentHex string) error {
if commentHex == "" {
func commentDelete(commentHex string, deleterHex string) error {
if commentHex == "" || deleterHex == "" {
return errorMissingField
}
statement := `
DELETE FROM comments
WHERE commentHex=$1;
UPDATE comments
SET
deleted = true,
markdown = '[deleted]',
html = '[deleted]',
commenterHex = 'anonymous',
deleterHex = $2,
deletionDate = $3
WHERE commentHex = $1;
`
_, err := db.Exec(statement, commentHex)
_, err := db.Exec(statement, commentHex, deleterHex, time.Now().UTC())
if err != nil {
// TODO: make sure this is the error is actually non-existant commentHex
@@ -41,6 +49,12 @@ func commentDeleteHandler(w http.ResponseWriter, r *http.Request) {
return
}
cm, err := commentGetByCommentHex(*x.CommentHex)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
domain, _, err := commentDomainPathGet(*x.CommentHex)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
@@ -53,12 +67,12 @@ func commentDeleteHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !isModerator {
if !isModerator && cm.CommenterHex != c.CommenterHex {
bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()})
return
}
if err = commentDelete(*x.CommentHex); err != nil {
if err = commentDelete(*x.CommentHex, c.CommenterHex); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}

View File

@@ -8,15 +8,16 @@ import (
func TestCommentDeleteBasics(t *testing.T) {
failTestOnError(t, setupTestEnv())
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
commentNew("temp-commenter-hex", "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC())
commenterHex := "temp-commenter-hex"
commentHex, _ := commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
commentNew(commenterHex, "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC())
if err := commentDelete(commentHex); err != nil {
if err := commentDelete(commentHex, commenterHex); err != nil {
t.Errorf("unexpected error deleting comment: %v", err)
return
}
c, _, _ := commentList("temp-commenter-hex", "example.com", "/path.html", false)
c, _, _ := commentList(commenterHex, "example.com", "/path.html", false)
if len(c) != 0 {
t.Errorf("expected no comments found %d comments", len(c))
@@ -27,7 +28,7 @@ func TestCommentDeleteBasics(t *testing.T) {
func TestCommentDeleteEmpty(t *testing.T) {
failTestOnError(t, setupTestEnv())
if err := commentDelete(""); err == nil {
if err := commentDelete("", "test-commenter-hex"); err == nil {
t.Errorf("expected error deleting comment with empty commentHex")
return
}

View File

@@ -8,7 +8,7 @@ func commentDomainPathGet(commentHex string) (string, string, error) {
}
statement := `
SELECT domain, path
SELECT domain, path
FROM comments
WHERE commentHex = $1;
`

66
api/comment_edit.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"net/http"
)
func commentEdit(commentHex string, markdown string) (string, error) {
if commentHex == "" {
return "", errorMissingField
}
html := markdownToHtml(markdown)
statement := `
UPDATE comments
SET markdown = $2, html = $3
WHERE commentHex=$1;
`
_, err := db.Exec(statement, commentHex, markdown, html)
if err != nil {
// TODO: make sure this is the error is actually non-existant commentHex
return "", errorNoSuchComment
}
return html, nil
}
func commentEditHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
CommenterToken *string `json:"commenterToken"`
CommentHex *string `json:"commentHex"`
Markdown *string `json:"markdown"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
cm, err := commentGetByCommentHex(*x.CommentHex)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if cm.CommenterHex != c.CommenterHex {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
html, err := commentEdit(*x.CommentHex, *x.Markdown)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "html": html})
}

50
api/comment_get.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import ()
var commentsRowColumns = `
comments.commentHex,
comments.commenterHex,
comments.markdown,
comments.html,
comments.parentHex,
comments.score,
comments.state,
comments.deleted,
comments.creationDate
`
func commentsRowScan(s sqlScanner, c *comment) error {
return s.Scan(
&c.CommentHex,
&c.CommenterHex,
&c.Markdown,
&c.Html,
&c.ParentHex,
&c.Score,
&c.State,
&c.Deleted,
&c.CreationDate,
)
}
func commentGetByCommentHex(commentHex string) (comment, error) {
if commentHex == "" {
return comment{}, errorMissingField
}
statement := `
SELECT ` + commentsRowColumns + `
FROM comments
WHERE comments.commentHex = $1;
`
row := db.QueryRow(statement, commentHex)
var c comment
if err := commentsRowScan(row, &c); err != nil {
// TODO: is this the only error?
return c, errorNoSuchComment
}
return c, nil
}

View File

@@ -20,6 +20,7 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
parentHex,
score,
state,
deleted,
creationDate
FROM comments
WHERE
@@ -66,6 +67,7 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
&c.ParentHex,
&c.Score,
&c.State,
&c.Deleted,
&c.CreationDate); err != nil {
return nil, nil, errorInternal
}
@@ -84,6 +86,10 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
}
}
if commenterHex != c.CommenterHex {
c.Markdown = ""
}
if !includeUnapproved {
c.State = ""
}
@@ -185,6 +191,7 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
"requireIdentification": d.RequireIdentification,
"isFrozen": d.State == "frozen",
"isModerator": isModerator,
"defaultSortPolicy": d.DefaultSortPolicy,
"attributes": p,
"configuredOauths": map[string]bool{
"commento": d.CommentoProvider,

View File

@@ -30,6 +30,10 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin
html := markdownToHtml(markdown)
if err = pageNew(domain, path); err != nil {
return "", err
}
statement := `
INSERT INTO
comments (commentHex, domain, path, commenterHex, parentHex, markdown, html, creationDate, state)
@@ -41,10 +45,6 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin
return "", errorInternal
}
if err = pageNew(domain, path); err != nil {
return "", err
}
return commentHex, nil
}
@@ -82,60 +82,36 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
return
}
// logic: (empty column indicates the value doesn't matter)
// | anonymous | moderator | requireIdentification | requireModeration | moderateAllAnonymous | approved? |
// |-----------+-----------+-----------------------+-------------------+----------------------+-----------|
// | yes | | | | no | yes |
// | yes | | | | yes | no |
// | no | yes | | | | yes |
// | no | no | | yes | | yes |
// | no | no | | no | | no |
var commenterHex string
var state string
var commenterHex, commenterName, commenterEmail, commenterLink string
var isModerator bool
if *x.CommenterToken == "anonymous" {
commenterHex = "anonymous"
if isSpam(*x.Domain, getIp(r), getUserAgent(r), "Anonymous", "", "", *x.Markdown) {
state = "flagged"
} else {
if d.ModerateAllAnonymous || d.RequireModeration {
state = "unapproved"
} else {
state = "approved"
}
}
commenterHex, commenterName, commenterEmail, commenterLink = "anonymous", "Anonymous", "", ""
} else {
c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
// cheaper than a SQL query as we already have this information
isModerator := false
commenterHex, commenterName, commenterEmail, commenterLink = c.CommenterHex, c.Name, c.Email, c.Link
for _, mod := range d.Moderators {
if mod.Email == c.Email {
isModerator = true
break
}
}
}
commenterHex = c.CommenterHex
if isModerator {
state = "approved"
} else {
if isSpam(*x.Domain, getIp(r), getUserAgent(r), c.Name, c.Email, c.Link, *x.Markdown) {
state = "flagged"
} else {
if d.RequireModeration {
state = "unapproved"
} else {
state = "approved"
}
}
}
var state string
if isModerator {
state = "approved"
} else if d.RequireModeration {
state = "unapproved"
} else if commenterHex == "anonymous" && d.ModerateAllAnonymous {
state = "unapproved"
} else if d.AutoSpamFilter && isSpam(*x.Domain, getIp(r), getUserAgent(r), commenterName, commenterEmail, commenterLink, *x.Markdown) {
state = "flagged"
} else {
state = "approved"
}
commentHex, err := commentNew(commenterHex, domain, path, *x.ParentHex, *x.Markdown, state, time.Now().UTC())
@@ -149,6 +125,6 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": html})
if smtpConfigured {
go emailNotificationNew(d, path, commenterHex, commentHex, *x.ParentHex, state)
go emailNotificationNew(d, path, commenterHex, commentHex, html, *x.ParentHex, state)
}
}

View File

@@ -39,10 +39,10 @@ func TestCommentNewUpvoted(t *testing.T) {
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
statement := `
SELECT score
FROM comments
WHERE commentHex = $1;
`
SELECT score
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, commentHex)
var score int

View File

@@ -19,7 +19,7 @@ func commentVote(commenterHex string, commentHex string, direction int) error {
var authorHex string
if err := row.Scan(&authorHex); err != nil {
logger.Errorf("erorr selecting authorHex for vote")
logger.Errorf("error selecting authorHex for vote")
return errorInternal
}
@@ -28,12 +28,12 @@ func commentVote(commenterHex string, commentHex string, direction int) error {
}
statement = `
INSERT INTO
votes (commentHex, commenterHex, direction, voteDate)
VALUES ($1, $2, $3, $4 )
ON CONFLICT (commentHex, commenterHex) DO
UPDATE SET direction = $3;
`
INSERT INTO
votes (commentHex, commenterHex, direction, voteDate)
VALUES ($1, $2, $3, $4 )
ON CONFLICT (commentHex, commenterHex) DO
UPDATE SET direction = $3;
`
_, err := db.Exec(statement, commentHex, commenterHex, direction, time.Now().UTC())
if err != nil {
logger.Errorf("error inserting/updating votes: %v", err)

View File

@@ -2,20 +2,42 @@ package main
import ()
var commentersRowColumns string = `
commenters.commenterHex,
commenters.email,
commenters.name,
commenters.link,
commenters.photo,
commenters.provider,
commenters.joinDate
`
func commentersRowScan(s sqlScanner, c *commenter) error {
return s.Scan(
&c.CommenterHex,
&c.Email,
&c.Name,
&c.Link,
&c.Photo,
&c.Provider,
&c.JoinDate,
)
}
func commenterGetByHex(commenterHex string) (commenter, error) {
if commenterHex == "" {
return commenter{}, errorMissingField
}
statement := `
SELECT commenterHex, email, name, link, photo, provider, joinDate
FROM commenters
WHERE commenterHex = $1;
`
SELECT ` + commentersRowColumns + `
FROM commenters
WHERE commenterHex = $1;
`
row := db.QueryRow(statement, commenterHex)
c := commenter{}
if err := row.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil {
var c commenter
if err := commentersRowScan(row, &c); err != nil {
// TODO: is this the only error?
return commenter{}, errorNoSuchCommenter
}
@@ -29,14 +51,14 @@ func commenterGetByEmail(provider string, email string) (commenter, error) {
}
statement := `
SELECT commenterHex, email, name, link, photo, provider, joinDate
FROM commenters
WHERE email = $1 AND provider = $2;
`
SELECT ` + commentersRowColumns + `
FROM commenters
WHERE email = $1 AND provider = $2;
`
row := db.QueryRow(statement, email, provider)
c := commenter{}
if err := row.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil {
var c commenter
if err := commentersRowScan(row, &c); err != nil {
// TODO: is this the only error?
return commenter{}, errorNoSuchCommenter
}
@@ -50,22 +72,22 @@ func commenterGetByCommenterToken(commenterToken string) (commenter, error) {
}
statement := `
SELECT commenterHex
FROM commenterSessions
WHERE commenterToken = $1;
SELECT ` + commentersRowColumns + `
FROM commenterSessions
JOIN commenters ON commenterSessions.commenterHex = commenters.commenterHex
WHERE commenterToken = $1;
`
row := db.QueryRow(statement, commenterToken)
var commenterHex string
if err := row.Scan(&commenterHex); err != nil {
// TODO: is the only error?
var c commenter
if err := commentersRowScan(row, &c); err != nil {
// TODO: is this the only error?
return commenter{}, errorNoSuchToken
}
if commenterHex == "none" {
if c.CommenterHex == "none" {
return commenter{}, errorNoSuchToken
}
// TODO: use a join instead of two queries?
return commenterGetByHex(commenterHex)
return c, nil
}

View File

@@ -74,5 +74,11 @@ func commenterLoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken, "commenter": c})
e, err := emailGet(c.Email)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken, "commenter": c, "email": e})
}

View File

@@ -1,8 +1,13 @@
package main
import (
"fmt"
"image/jpeg"
"io"
"net/http"
"strings"
"github.com/disintegration/imaging"
)
func commenterPhotoHandler(w http.ResponseWriter, r *http.Request) {
@@ -14,13 +19,15 @@ func commenterPhotoHandler(w http.ResponseWriter, r *http.Request) {
url := c.Photo
if c.Provider == "google" {
url += "?sz=50"
if strings.HasSuffix(url, "photo.jpg") {
url += "?sz=38"
} else {
url += "=s38"
}
} else if c.Provider == "github" {
url += "&s=50"
} else if c.Provider == "twitter" {
url += "?size=normal"
url += "&s=38"
} else if c.Provider == "gitlab" {
url += "?width=50"
url += "?width=38"
}
resp, err := http.Get(url)
@@ -30,5 +37,23 @@ func commenterPhotoHandler(w http.ResponseWriter, r *http.Request) {
}
defer resp.Body.Close()
io.Copy(w, resp.Body)
if c.Provider != "commento" { // Custom URL avatars need to be resized.
io.Copy(w, resp.Body)
return
}
// Limit the size of the response to 128 KiB to prevent DoS attacks
// that exhaust memory.
limitedResp := &io.LimitedReader{R: resp.Body, N: 128 * 1024}
img, err := jpeg.Decode(limitedResp)
if err != nil {
fmt.Fprintf(w, "JPEG decode failed: %v\n", err)
return
}
if err = imaging.Encode(w, imaging.Resize(img, 38, 0, imaging.Lanczos), imaging.JPEG); err != nil {
fmt.Fprintf(w, "image encoding failed: %v\n", err)
return
}
}

View File

@@ -8,10 +8,10 @@ func commenterSessionUpdate(commenterToken string, commenterHex string) error {
}
statement := `
UPDATE commenterSessions
SET commenterHex = $2
WHERE commenterToken = $1;
`
UPDATE commenterSessions
SET commenterHex = $2
WHERE commenterToken = $1;
`
_, err := db.Exec(statement, commenterToken, commenterHex)
if err != nil {
logger.Errorf("error updating commenterHex: %v", err)

69
api/commenter_update.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"net/http"
)
func commenterUpdate(commenterHex string, email string, name string, link string, photo string, provider string) error {
if email == "" || name == "" || photo == "" || provider == "" {
return errorMissingField
}
// See utils_sanitise.go's documentation on isHttpsUrl. This is not a URL
// validator, just an XSS preventor.
// TODO: reject URLs instead of malforming them.
if link == "" {
link = "undefined"
} else if link != "undefined" && !isHttpsUrl(link) {
link = "https://" + link
}
statement := `
UPDATE commenters
SET email = $3, name = $4, link = $5, photo = $6
WHERE commenterHex = $1 and provider = $2;
`
_, err := db.Exec(statement, commenterHex, provider, email, name, link, photo)
if err != nil {
logger.Errorf("cannot update commenter: %v", err)
return errorInternal
}
return nil
}
func commenterUpdateHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
CommenterToken *string `json:"commenterToken"`
Name *string `json:"name"`
Email *string `json:"email"`
Link *string `json:"link"`
Photo *string `json:"photo"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
c, err := commenterGetByCommenterToken(*x.CommenterToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if c.Provider != "commento" {
bodyMarshal(w, response{"success": false, "message": errorCannotUpdateOauthProfile.Error()})
return
}
*x.Email = c.Email
if err = commenterUpdate(c.CommenterHex, *x.Email, *x.Name, *x.Link, *x.Photo, c.Provider); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -56,6 +56,13 @@ func configParse() error {
"GITLAB_KEY": "",
"GITLAB_SECRET": "",
"GITLAB_URL": "https://gitlab.com",
}
if os.Getenv("COMMENTO_CONFIG_FILE") != "" {
if err := configFileLoad(os.Getenv("COMMENTO_CONFIG_FILE")); err != nil {
return err
}
}
for key, value := range defaults {
@@ -66,12 +73,6 @@ func configParse() error {
}
}
if os.Getenv("CONFIG_FILE") != "" {
if err := configFileLoad(os.Getenv("CONFIG_FILE")); err != nil {
return err
}
}
// Mandatory config parameters
for _, env := range []string{"POSTGRES", "PORT", "ORIGIN", "FORBID_NEW_OWNERS", "MAX_IDLE_PG_CONNECTIONS"} {
if os.Getenv(env) == "" {

View File

@@ -41,11 +41,12 @@ func configFileLoad(filepath string) error {
continue
}
if os.Getenv(key[9:]) != "" {
if os.Getenv(key) != "" {
// Config files have lower precedence.
continue
}
os.Setenv(key[9:], value)
os.Setenv(key, value)
}
return nil

View File

@@ -37,19 +37,19 @@ func TestConfigFileLoadBasics(t *testing.T) {
return
}
os.Setenv("PORT", "9000")
os.Setenv("COMMENTO_PORT", "9000")
if err := configFileLoad(f.Name()); err != nil {
t.Errorf("unexpected error loading config file: %v", err)
return
}
if os.Getenv("PORT") != "9000" {
t.Errorf("expected PORT=9000 got PORT=%s", os.Getenv("PORT"))
if os.Getenv("COMMENTO_PORT") != "9000" {
t.Errorf("expected COMMENTO_PORT=9000 got COMMENTO_PORT=%s", os.Getenv("COMMENTO_PORT"))
return
}
if os.Getenv("GZIP_STATIC") != "true" {
t.Errorf("expected GZIP_STATIC=true got GZIP_STATIC=%s", os.Getenv("GZIP_STATIC"))
if os.Getenv("COMMENTO_GZIP_STATIC") != "true" {
t.Errorf("expected COMMENTO_GZIP_STATIC=true got COMMENTO_GZIP_STATIC=%s", os.Getenv("COMMENTO_GZIP_STATIC"))
return
}
}

View File

@@ -21,6 +21,7 @@ func TestConfigParseBasics(t *testing.T) {
os.Setenv("COMMENTO_BIND_ADDRESS", "192.168.1.100")
os.Setenv("COMMENTO_PORT", "")
if err := configParse(); err != nil {
t.Errorf("unexpected error when parsing config: %v", err)
return

View File

@@ -1,3 +1,3 @@
package main
var version = "v1.7.0"
var version string

View File

@@ -1,66 +0,0 @@
package main
import (
"time"
)
func emailNotificationBegin() error {
go func() {
for {
statement := `
SELECT email, sendModeratorNotifications, sendReplyNotifications
FROM emails
WHERE pendingEmails > 0 AND lastEmailNotificationDate < $1;
`
rows, err := db.Query(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute))
if err != nil {
logger.Errorf("cannot query domains: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var email string
var sendModeratorNotifications bool
var sendReplyNotifications bool
if err = rows.Scan(&email, &sendModeratorNotifications, &sendReplyNotifications); err != nil {
logger.Errorf("cannot scan email in cron job to send notifications: %v", err)
continue
}
if _, ok := emailQueue[email]; !ok {
if err = emailNotificationPendingReset(email); err != nil {
logger.Errorf("error resetting pendingEmails: %v", err)
continue
}
}
cont := true
kindListMap := map[string][]emailNotification{}
for cont {
select {
case e := <-emailQueue[email]:
if _, ok := kindListMap[e.Kind]; !ok {
kindListMap[e.Kind] = []emailNotification{}
}
if (e.Kind == "reply" && sendReplyNotifications) || sendModeratorNotifications {
kindListMap[e.Kind] = append(kindListMap[e.Kind], e)
}
default:
cont = false
break
}
}
for kind, list := range kindListMap {
go emailNotificationSend(email, kind, list)
}
}
time.Sleep(10 * time.Minute)
}
}()
return nil
}

View File

@@ -3,6 +3,7 @@ package main
import (
"database/sql"
_ "github.com/lib/pq"
"net/url"
"os"
"strconv"
"time"
@@ -10,9 +11,14 @@ import (
func dbConnect(retriesLeft int) error {
con := os.Getenv("POSTGRES")
logger.Infof("opening connection to postgres: %s", con)
u, err := url.Parse(con)
if err != nil {
logger.Errorf("invalid postgres connection URI: %v", err)
return err
}
u.User = url.UserPassword(u.User.Username(), "redacted")
logger.Infof("opening connection to postgres: %s", u.String())
var err error
db, err = sql.Open("postgres", con)
if err != nil {
logger.Errorf("cannot open connection to postgres: %v", err)
@@ -32,10 +38,10 @@ func dbConnect(retriesLeft int) error {
}
statement := `
CREATE TABLE IF NOT EXISTS migrations (
filename TEXT NOT NULL UNIQUE
);
`
CREATE TABLE IF NOT EXISTS migrations (
filename TEXT NOT NULL UNIQUE
);
`
_, err = db.Exec(statement)
if err != nil {
logger.Errorf("cannot create migrations table: %v", err)

View File

@@ -22,9 +22,9 @@ func migrateFromDir(dir string) error {
}
statement := `
SELECT filename
FROM migrations;
`
SELECT filename
FROM migrations;
`
rows, err := db.Query(statement)
if err != nil {
logger.Errorf("cannot query migrations: %v", err)
@@ -63,10 +63,10 @@ func migrateFromDir(dir string) error {
}
statement = `
INSERT INTO
migrations (filename)
VALUES ($1 );
`
INSERT INTO
migrations (filename)
VALUES ($1 );
`
_, err = db.Exec(statement, file.Name())
if err != nil {
logger.Errorf("cannot insert filename into the migrations table: %v", err)

View File

@@ -25,4 +25,5 @@ type domain struct {
SsoProvider bool `json:"ssoProvider"`
SsoSecret string `json:"ssoSecret"`
SsoUrl string `json:"ssoUrl"`
DefaultSortPolicy string `json:"defaultSortPolicy"`
}

View File

@@ -10,8 +10,7 @@ func domainDelete(domain string) error {
}
statement := `
DELETE FROM
domains
DELETE FROM domains
WHERE domain = $1;
`
_, err := db.Exec(statement, domain)
@@ -25,7 +24,7 @@ func domainDelete(domain string) error {
`
_, err = db.Exec(statement, domain)
if err != nil {
logger.Errorf("cannot delete views: %v", err)
logger.Errorf("cannot delete domain from views: %v", err)
return errorInternal
}
@@ -35,7 +34,17 @@ func domainDelete(domain string) error {
`
_, err = db.Exec(statement, domain)
if err != nil {
logger.Errorf("cannot delete domain moderators: %v", err)
logger.Errorf("cannot delete domain from moderators: %v", err)
return errorInternal
}
statement = `
DELETE FROM ssotokens
WHERE ssotokens.domain = $1;
`
_, err = db.Exec(statement, domain)
if err != nil {
logger.Errorf("cannot delete domain from ssotokens: %v", err)
return errorInternal
}

View File

@@ -15,13 +15,7 @@ func domainExportBeginError(email string, toName string, domain string, err erro
}
func domainExportBegin(email string, toName string, domain string) {
type dataExport struct {
Version int `json:"version"`
Comments []comment `json:"comments"`
Commenters []commenter `json:"commenters"`
}
e := dataExport{Version: 1, Comments: []comment{}, Commenters: []commenter{}}
e := commentoExportV1{Version: 1, Comments: []comment{}, Commenters: []commenter{}}
statement := `
SELECT commentHex, domain, path, commenterHex, markdown, parentHex, score, state, creationDate

View File

@@ -27,7 +27,6 @@ func domainExportDownloadHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Error: that exportHex does not exist\n")
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s-%v.gz"`, domain, creationDate.Unix()))
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s-%v.json.gz"`, domain, creationDate.Unix()))
w.Write(binData)
}

View File

@@ -2,40 +2,31 @@ package main
import ()
func domainGet(dmn string) (domain, error) {
if dmn == "" {
return domain{}, errorMissingField
}
var domainsRowColumns = `
domains.domain,
domains.ownerHex,
domains.name,
domains.creationDate,
domains.state,
domains.importedComments,
domains.autoSpamFilter,
domains.requireModeration,
domains.requireIdentification,
domains.moderateAllAnonymous,
domains.emailNotificationPolicy,
domains.commentoProvider,
domains.googleProvider,
domains.twitterProvider,
domains.githubProvider,
domains.gitlabProvider,
domains.ssoProvider,
domains.ssoSecret,
domains.ssoUrl,
domains.defaultSortPolicy
`
statement := `
SELECT
domain,
ownerHex,
name,
creationDate,
state,
importedComments,
autoSpamFilter,
requireModeration,
requireIdentification,
moderateAllAnonymous,
emailNotificationPolicy,
commentoProvider,
googleProvider,
twitterProvider,
githubProvider,
gitlabProvider,
ssoProvider,
ssoSecret,
ssoUrl
FROM domains
WHERE domain = $1;
`
row := db.QueryRow(statement, dmn)
var err error
d := domain{}
if err = row.Scan(
func domainsRowScan(s sqlScanner, d *domain) error {
return s.Scan(
&d.Domain,
&d.OwnerHex,
&d.Name,
@@ -54,7 +45,26 @@ func domainGet(dmn string) (domain, error) {
&d.GitlabProvider,
&d.SsoProvider,
&d.SsoSecret,
&d.SsoUrl); err != nil {
&d.SsoUrl,
&d.DefaultSortPolicy,
)
}
func domainGet(dmn string) (domain, error) {
if dmn == "" {
return domain{}, errorMissingField
}
statement := `
SELECT ` + domainsRowColumns + `
FROM domains
WHERE domain = $1;
`
row := db.QueryRow(statement, dmn)
var err error
d := domain{}
if err = domainsRowScan(row, &d); err != nil {
return d, errorNoSuchDomain
}

View File

@@ -0,0 +1,168 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"io/ioutil"
"net/http"
)
type commentoExportV1 struct {
Version int `json:"version"`
Comments []comment `json:"comments"`
Commenters []commenter `json:"commenters"`
}
func domainImportCommento(domain string, url string) (int, error) {
if domain == "" || url == "" {
return 0, errorMissingField
}
resp, err := http.Get(url)
if err != nil {
logger.Errorf("cannot get url: %v", err)
return 0, errorCannotDownloadCommento
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
logger.Errorf("cannot read body: %v", err)
return 0, errorCannotDownloadCommento
}
zr, err := gzip.NewReader(bytes.NewBuffer(body))
if err != nil {
logger.Errorf("cannot create gzip reader: %v", err)
return 0, errorInternal
}
contents, err := ioutil.ReadAll(zr)
if err != nil {
logger.Errorf("cannot read gzip contents uncompressed: %v", err)
return 0, errorInternal
}
var data commentoExportV1
if err := json.Unmarshal(contents, &data); err != nil {
logger.Errorf("cannot unmarshal JSON at %s: %v", url, err)
return 0, errorInternal
}
if data.Version != 1 {
logger.Errorf("invalid data version (got %d, want 1): %v", data.Version, err)
return 0, errorUnsupportedCommentoImportVersion
}
// Check if imported commentedHex or email exists, creating a map of
// commenterHex (old hex, new hex)
commenterHex := map[string]string{"anonymous": "anonymous"}
for _, commenter := range data.Commenters {
c, err := commenterGetByEmail("commento", commenter.Email)
if err != nil && err != errorNoSuchCommenter {
logger.Errorf("cannot get commenter by email: %v", err)
return 0, errorInternal
}
if err == nil {
commenterHex[commenter.CommenterHex] = c.CommenterHex
continue
}
randomPassword, err := randomHex(32)
if err != nil {
logger.Errorf("cannot generate random password for new commenter: %v", err)
return 0, errorInternal
}
commenterHex[commenter.CommenterHex], err = commenterNew(commenter.Email,
commenter.Name, commenter.Link, commenter.Photo, "commento", randomPassword)
if err != nil {
return 0, err
}
}
// Create a map of (parent hex, comments)
comments := make(map[string][]comment)
for _, comment := range data.Comments {
parentHex := comment.ParentHex
comments[parentHex] = append(comments[parentHex], comment)
}
// Import comments, creating a map of comment hex (old hex, new hex)
commentHex := map[string]string{"root": "root"}
numImported := 0
keys := []string{"root"}
for i := 0; i < len(keys); i++ {
for _, comment := range comments[keys[i]] {
cHex, ok := commenterHex[comment.CommenterHex]
if !ok {
logger.Errorf("cannot get commenter: %v", err)
return numImported, errorInternal
}
parentHex, ok := commentHex[comment.ParentHex]
if !ok {
logger.Errorf("cannot get parent comment: %v", err)
return numImported, errorInternal
}
hex, err := commentNew(
cHex,
domain,
comment.Path,
parentHex,
comment.Markdown,
comment.State,
comment.CreationDate)
if err != nil {
return numImported, err
}
commentHex[comment.CommentHex] = hex
numImported++
keys = append(keys, comment.CommentHex)
}
}
return numImported, nil
}
func domainImportCommentoHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
OwnerToken *string `json:"ownerToken"`
Domain *string `json:"domain"`
URL *string `json:"url"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
o, err := ownerGetByOwnerToken(*x.OwnerToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
domain := domainStrip(*x.Domain)
isOwner, err := domainOwnershipVerify(o.OwnerHex, domain)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if !isOwner {
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
return
}
numImported, err := domainImportCommento(domain, *x.URL)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "numImported": numImported})
}

View File

@@ -0,0 +1,121 @@
package main
import (
"compress/gzip"
"encoding/json"
"fmt"
"net"
"net/http"
"testing"
"time"
)
func TestImportCommento(t *testing.T) {
failTestOnError(t, setupTestEnv())
// Create JSON data
data := commentoExportV1{
Version: 1,
Comments: []comment{
{
CommentHex: "5a349182b3b8e25107ab2b12e514f40fe0b69160a334019491d7c204aff6fdc2",
Domain: "localhost:1313",
Path: "/post/first-post/",
CommenterHex: "anonymous",
Markdown: "This is a reply!",
Html: "",
ParentHex: "7ed60b1227f6c4850258a2ac0304e1936770117d6f3a379655f775c46b9f13cd",
Score: 0,
State: "approved",
CreationDate: timeParse(t, "2020-01-27T14:08:44.061525Z"),
Direction: 0,
Deleted: false,
},
{
CommentHex: "7ed60b1227f6c4850258a2ac0304e1936770117d6f3a379655f775c46b9f13cd",
Domain: "localhost:1313",
Path: "/post/first-post/",
CommenterHex: "anonymous",
Markdown: "This is a comment!",
Html: "",
ParentHex: "root",
Score: 0,
State: "approved",
CreationDate: timeParse(t, "2020-01-27T14:07:49.244432Z"),
Direction: 0,
Deleted: false,
},
{
CommentHex: "a7c84f251b5a09d5b65e902cbe90633646437acefa3a52b761fee94002ac54c7",
Domain: "localhost:1313",
Path: "/post/first-post/",
CommenterHex: "4629a8216538b73987597d66f266c1a1801b0451f99cf066e7122aa104ef3b07",
Markdown: "This is a test comment, bar foo\n\n#Here is something big\n\n```\nhere code();\n```",
Html: "",
ParentHex: "root",
Score: 0,
State: "approved",
CreationDate: timeParse(t, "2020-01-27T14:20:21.101653Z"),
Direction: 0,
Deleted: false,
},
},
Commenters: []commenter{
{
CommenterHex: "4629a8216538b73987597d66f266c1a1801b0451f99cf066e7122aa104ef3b07",
Email: "john@doe.com",
Name: "John Doe",
Link: "https://john.doe",
Photo: "undefined",
Provider: "commento",
JoinDate: timeParse(t, "2020-01-27T14:17:59.298737Z"),
IsModerator: false,
},
},
}
// Create listener with random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Errorf("couldn't create listener: %v", err)
return
}
defer func() {
_ = listener.Close()
}()
port := listener.Addr().(*net.TCPAddr).Port
// Launch http server serving commento json gzipped data
go func() {
http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gzipper := gzip.NewWriter(w)
defer func() {
_ = gzipper.Close()
}()
encoder := json.NewEncoder(gzipper)
if err := encoder.Encode(data); err != nil {
t.Errorf("couldn't write data: %v", err)
}
}))
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
domainNew("temp-owner-hex", "Example", "example.com")
n, err := domainImportCommento("example.com", url)
if err != nil {
t.Errorf("unexpected error importing comments: %v", err)
return
}
if n != len(data.Comments) {
t.Errorf("imported comments missmatch (got %d, want %d)", n, len(data.Comments))
}
}
func timeParse(t *testing.T, s string) time.Time {
time, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
t.Errorf("couldn't parse time: %v", err)
}
return time
}

View File

@@ -10,26 +10,7 @@ func domainList(ownerHex string) ([]domain, error) {
}
statement := `
SELECT
domain,
ownerHex,
name,
creationDate,
state,
importedComments,
autoSpamFilter,
requireModeration,
requireIdentification,
moderateAllAnonymous,
emailNotificationPolicy,
commentoProvider,
googleProvider,
twitterProvider,
githubProvider,
gitlabProvider,
ssoProvider,
ssoSecret,
ssoUrl
SELECT ` + domainsRowColumns + `
FROM domains
WHERE ownerHex=$1;
`
@@ -42,27 +23,8 @@ func domainList(ownerHex string) ([]domain, error) {
domains := []domain{}
for rows.Next() {
d := domain{}
if err = rows.Scan(
&d.Domain,
&d.OwnerHex,
&d.Name,
&d.CreationDate,
&d.State,
&d.ImportedComments,
&d.AutoSpamFilter,
&d.RequireModeration,
&d.RequireIdentification,
&d.ModerateAllAnonymous,
&d.EmailNotificationPolicy,
&d.CommentoProvider,
&d.GoogleProvider,
&d.TwitterProvider,
&d.GithubProvider,
&d.GitlabProvider,
&d.SsoProvider,
&d.SsoSecret,
&d.SsoUrl); err != nil {
var d domain
if err = domainsRowScan(rows, &d); err != nil {
logger.Errorf("cannot Scan domain: %v", err)
return nil, errorInternal
}

View File

@@ -25,7 +25,8 @@ func domainUpdate(d domain) error {
githubProvider=$12,
gitlabProvider=$13,
ssoProvider=$14,
ssoUrl=$15
ssoUrl=$15,
defaultSortPolicy=$16
WHERE domain=$1;
`
@@ -44,7 +45,8 @@ func domainUpdate(d domain) error {
d.GithubProvider,
d.GitlabProvider,
d.SsoProvider,
d.SsoUrl)
d.SsoUrl,
d.DefaultSortPolicy)
if err != nil {
logger.Errorf("cannot update non-moderators: %v", err)
return errorInternal

View File

@@ -8,7 +8,6 @@ type email struct {
Email string `json:"email"`
UnsubscribeSecretHex string `json:"unsubscribeSecretHex"`
LastEmailNotificationDate time.Time `json:"lastEmailNotificationDate"`
PendingEmails int `json:"-"`
SendReplyNotifications bool `json:"sendReplyNotifications"`
SendModeratorNotifications bool `json:"sendModeratorNotifications"`
}

View File

@@ -4,16 +4,34 @@ import (
"net/http"
)
var emailsRowColumns = `
emails.email,
emails.unsubscribeSecretHex,
emails.lastEmailNotificationDate,
emails.sendReplyNotifications,
emails.sendModeratorNotifications
`
func emailsRowScan(s sqlScanner, e *email) error {
return s.Scan(
&e.Email,
&e.UnsubscribeSecretHex,
&e.LastEmailNotificationDate,
&e.SendReplyNotifications,
&e.SendModeratorNotifications,
)
}
func emailGet(em string) (email, error) {
statement := `
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
SELECT ` + emailsRowColumns + `
FROM emails
WHERE email = $1;
`
row := db.QueryRow(statement, em)
e := email{}
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); err != nil {
var e email
if err := emailsRowScan(row, &e); err != nil {
// TODO: is this the only error?
return e, errorNoSuchEmail
}
@@ -23,14 +41,14 @@ func emailGet(em string) (email, error) {
func emailGetByUnsubscribeSecretHex(unsubscribeSecretHex string) (email, error) {
statement := `
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
SELECT ` + emailsRowColumns + `
FROM emails
WHERE unsubscribeSecretHex = $1;
`
row := db.QueryRow(statement, unsubscribeSecretHex)
e := email{}
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); err != nil {
if err := emailsRowScan(row, &e); err != nil {
// TODO: is this the only error?
return e, errorNoSuchUnsubscribeSecretHex
}

View File

@@ -7,18 +7,7 @@ import (
func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
unsubscribeSecretHex := r.FormValue("unsubscribeSecretHex")
e, err := emailGetByUnsubscribeSecretHex(unsubscribeSecretHex)
if err != nil {
fmt.Fprintf(w, "error: %v", err.Error())
return
}
action := r.FormValue("action")
if action != "delete" && action != "approve" {
fmt.Fprintf(w, "error: invalid action")
return
}
commentHex := r.FormValue("commentHex")
if commentHex == "" {
fmt.Fprintf(w, "error: invalid commentHex")
@@ -26,23 +15,35 @@ func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
}
statement := `
SELECT domain
SELECT domain, deleted
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, commentHex)
var domain string
if err = row.Scan(&domain); err != nil {
var deleted bool
if err := row.Scan(&domain, &deleted); err != nil {
// TODO: is this the only error?
fmt.Fprintf(w, "error: no such comment found (perhaps it has been deleted?)")
return
}
if deleted {
fmt.Fprintf(w, "error: that comment has already been deleted")
return
}
e, err := emailGetByUnsubscribeSecretHex(unsubscribeSecretHex)
if err != nil {
fmt.Fprintf(w, "error: %v", err.Error())
return
}
isModerator, err := isDomainModerator(domain, e.Email)
if err != nil {
logger.Errorf("error checking if %s is a moderator: %v", e.Email, err)
fmt.Fprintf(w, "error checking if %s is a moderator: %v", e.Email, err)
fmt.Fprintf(w, "error: %v", errorInternal)
return
}
@@ -51,10 +52,31 @@ func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
return
}
if action == "approve" {
// Do not use commenterGetByEmail here because we don't know which provider
// should be used. This was poor design on multiple fronts on my part, but
// let's deal with that later. For now, it suffices to match the
// deleter/approver with any account owned by the same email.
statement = `
SELECT commenterHex
FROM commenters
WHERE email = $1;
`
row = db.QueryRow(statement, e.Email)
var commenterHex string
if err = row.Scan(&commenterHex); err != nil {
logger.Errorf("cannot retrieve commenterHex by email %q: %v", e.Email, err)
fmt.Fprintf(w, "error: %v", errorInternal)
return
}
switch action {
case "approve":
err = commentApprove(commentHex)
} else {
err = commentDelete(commentHex)
case "delete":
err = commentDelete(commentHex, commenterHex)
default:
err = errorInvalidAction
}
if err != nil {

View File

@@ -1,8 +1,6 @@
package main
import (
"time"
)
import ()
type emailNotification struct {
Email string
@@ -13,69 +11,3 @@ type emailNotification struct {
CommentHex string
Kind string
}
var emailQueue map[string](chan emailNotification) = map[string](chan emailNotification){}
func emailNotificationPendingResetAll() error {
statement := `
UPDATE emails
SET pendingEmails = 0;
`
_, err := db.Exec(statement)
if err != nil {
logger.Errorf("cannot reset pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationPendingIncrement(email string) error {
statement := `
UPDATE emails
SET pendingEmails = pendingEmails + 1
WHERE email = $1;
`
_, err := db.Exec(statement, email)
if err != nil {
logger.Errorf("cannot increment pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationPendingReset(email string) error {
statement := `
UPDATE emails
SET pendingEmails = 0, lastEmailNotificationDate = $2
WHERE email = $1;
`
_, err := db.Exec(statement, email, time.Now().UTC())
if err != nil {
logger.Errorf("cannot decrement pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationEnqueue(e emailNotification) error {
if err := emailNotificationPendingIncrement(e.Email); err != nil {
logger.Errorf("cannot increment pendingEmails when enqueueing: %v", err)
return err
}
if _, ok := emailQueue[e.Email]; !ok {
// don't enqueue more than 10 emails as we won't send more than 10 comments
// in one email anyway
emailQueue[e.Email] = make(chan emailNotification, 10)
}
select {
case emailQueue[e.Email] <- e:
default:
}
return nil
}

View File

@@ -2,13 +2,11 @@ package main
import ()
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, state string) {
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, html string, state string) {
if d.EmailNotificationPolicy == "none" {
return
}
// We'll need to check again when we're sending in case the comment was
// approved midway anyway.
if d.EmailNotificationPolicy == "pending-moderation" && state == "approved" {
return
}
@@ -39,20 +37,37 @@ func emailNotificationModerator(d domain, path string, title string, commenterHe
continue
}
emailNotificationPendingIncrement(m.Email)
emailNotificationEnqueue(emailNotification{
Email: m.Email,
CommenterName: commenterName,
Domain: d.Domain,
Path: path,
Title: title,
CommentHex: commentHex,
Kind: kind,
})
e, err := emailGet(m.Email)
if err != nil {
// No such email.
continue
}
if !e.SendModeratorNotifications {
continue
}
statement := `
SELECT name
FROM commenters
WHERE email = $1;
`
row := db.QueryRow(statement, m.Email)
var name string
if err := row.Scan(&name); err != nil {
// The moderator has probably not created a commenter account.
// We should only send emails to people who signed up, so skip.
continue
}
if err := smtpEmailNotification(m.Email, name, kind, d.Domain, path, commentHex, commenterName, title, html, e.UnsubscribeSecretHex); err != nil {
logger.Errorf("error sending email to %s: %v", m.Email, err)
continue
}
}
}
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex string, parentHex string, state string) {
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex string, html string, parentHex string, state string) {
// No reply notifications for root comments.
if parentHex == "root" {
return
@@ -105,20 +120,20 @@ func emailNotificationReply(d domain, path string, title string, commenterHex st
commenterName = c.Name
}
// We'll check if they want to receive reply notifications later at the time
// of sending.
emailNotificationEnqueue(emailNotification{
Email: pc.Email,
CommenterName: commenterName,
Domain: d.Domain,
Path: path,
Title: title,
CommentHex: commentHex,
Kind: "reply",
})
epc, err := emailGet(pc.Email)
if err != nil {
// No such email.
return
}
if !epc.SendReplyNotifications {
return
}
smtpEmailNotification(pc.Email, pc.Name, "reply", d.Domain, path, commentHex, commenterName, title, html, epc.UnsubscribeSecretHex)
}
func emailNotificationNew(d domain, path string, commenterHex string, commentHex string, parentHex string, state string) {
func emailNotificationNew(d domain, path string, commenterHex string, commentHex string, html string, parentHex string, state string) {
p, err := pageGet(d.Domain, path)
if err != nil {
logger.Errorf("cannot get page to send email notification: %v", err)
@@ -128,11 +143,12 @@ func emailNotificationNew(d domain, path string, commenterHex string, commentHex
if p.Title == "" {
p.Title, err = pageTitleUpdate(d.Domain, path)
if err != nil {
logger.Errorf("cannot update/get page title to send email notification: %v", err)
return
// Not being able to update a page title isn't serious enough to skip an
// email notification.
p.Title = d.Domain
}
}
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, state)
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, parentHex, state)
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, html, state)
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, html, parentHex, state)
}

View File

@@ -1,63 +0,0 @@
package main
import (
"html/template"
)
func emailNotificationSend(em string, kind string, notifications []emailNotification) {
if len(notifications) == 0 {
return
}
e, err := emailGet(em)
if err != nil {
logger.Errorf("cannot get email: %v", err)
return
}
messages := []emailNotificationText{}
for _, notification := range notifications {
statement := `
SELECT html
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, notification.CommentHex)
var html string
if err = row.Scan(&html); err != nil {
// the comment was deleted?
// TODO: is this the only error?
return
}
messages = append(messages, emailNotificationText{
emailNotification: notification,
Html: template.HTML(html),
})
}
statement := `
SELECT name
FROM commenters
WHERE email = $1;
`
row := db.QueryRow(statement, em)
var name string
if err := row.Scan(&name); err != nil {
// The moderator has probably not created a commenter account. Let's just
// use their email as name.
name = nameFromEmail(em)
}
if err := emailNotificationPendingReset(em); err != nil {
logger.Errorf("cannot reset after email notification: %v", err)
return
}
if err := smtpEmailNotification(em, name, e.UnsubscribeSecretHex, messages, kind); err != nil {
logger.Errorf("cannot send email notification: %v", err)
return
}
}

View File

@@ -37,6 +37,7 @@ var errorNotModerator = errors.New("You need to be a moderator to do that.")
var errorNotADirectory = errors.New("The given path is not a directory.")
var errorGzip = errors.New("Cannot GZip content.")
var errorCannotDownloadDisqus = errors.New("We could not download your Disqus export file.")
var errorCannotDownloadCommento = errors.New("We could not download your Commento export file.")
var errorSelfVote = errors.New("You cannot vote on your own comment.")
var errorInvalidConfigFile = errors.New("Invalid config file.")
var errorInvalidConfigValue = errors.New("Invalid config value.")
@@ -46,3 +47,9 @@ var errorDatabaseMigration = errors.New("Encountered error applying database mig
var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.")
var errorEmptyPaths = errors.New("Empty paths field.")
var errorInvalidDomain = errors.New("Invalid domain name. Do not include the URL path after the forward slash.")
var errorInvalidEntity = errors.New("That entity does not exist.")
var errorCannotDeleteOwnerWithActiveDomains = errors.New("You cannot delete your account until all domains associated with your account are deleted.")
var errorNoSuchOwner = errors.New("No such owner.")
var errorCannotUpdateOauthProfile = errors.New("You cannot update the profile of an external account managed by third-party log in. Please use the appropriate platform to update your details.")
var errorUnsupportedCommentoImportVersion = errors.New("Unsupported Commento import format version.")
var errorInvalidAction = errors.New("Invalid action.")

97
api/forgot.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"net/http"
"time"
)
func forgot(email string, entity string) error {
if email == "" {
return errorMissingField
}
if entity != "owner" && entity != "commenter" {
return errorInvalidEntity
}
if !smtpConfigured {
return errorSmtpNotConfigured
}
var hex string
var name string
if entity == "owner" {
o, err := ownerGetByEmail(email)
if err != nil {
if err == errorNoSuchEmail {
// TODO: use a more random time instead.
time.Sleep(1 * time.Second)
return nil
} else {
logger.Errorf("cannot get owner by email: %v", err)
return errorInternal
}
}
hex = o.OwnerHex
name = o.Name
} else {
c, err := commenterGetByEmail("commento", email)
if err != nil {
if err == errorNoSuchEmail {
// TODO: use a more random time instead.
time.Sleep(1 * time.Second)
return nil
} else {
logger.Errorf("cannot get commenter by email: %v", err)
return errorInternal
}
}
hex = c.CommenterHex
name = c.Name
}
resetHex, err := randomHex(32)
if err != nil {
return err
}
var statement string
statement = `
INSERT INTO
resetHexes (resetHex, hex, entity, sendDate)
VALUES ($1, $2, $3, $4 );
`
_, err = db.Exec(statement, resetHex, hex, entity, time.Now().UTC())
if err != nil {
logger.Errorf("cannot insert resetHex: %v", err)
return errorInternal
}
err = smtpResetHex(email, name, resetHex)
if err != nil {
return err
}
return nil
}
func forgotHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
Email *string `json:"email"`
Entity *string `json:"entity"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err := forgot(*x.Email, *x.Entity); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

23
api/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module gitlab.com/commento/commento/api
go 1.12
require (
cloud.google.com/go v0.26.0 // indirect
github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047
github.com/disintegration/imaging v1.6.2
github.com/golang/protobuf v1.1.0 // indirect
github.com/gomodule/oauth1 v0.0.0-20181215000758-9a59ed3b0a84
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/handlers v1.4.0
github.com/gorilla/mux v1.6.2
github.com/lib/pq v0.0.0-20180523175426-90697d60dd84
github.com/lunny/html2md v0.0.0-20180317074532-13aaeeae9fb2
github.com/microcosm-cc/bluemonday v1.0.0
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473
github.com/russross/blackfriday v1.5.1
golang.org/x/crypto v0.0.0-20180808211826-de0752318171
golang.org/x/net v0.0.0-20180811021610-c39426892332
golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc
google.golang.org/appengine v1.1.0 // indirect
)

37
api/go.sum Normal file
View File

@@ -0,0 +1,37 @@
cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047 h1:ZC99vhH6LlWY7bstM3JhEZl1c0a0DWZPFe7+hvRwTlc=
github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047/go.mod h1:DU/mtPMgEDGGfgxGATXm2Br5+F7JOClQj9nHVKZMlns=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/golang/protobuf v1.1.0 h1:0iH4Ffd/meGoXqF2lSAhZHt8X+cPgkfn/cb6Cce5Vpc=
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomodule/oauth1 v0.0.0-20181215000758-9a59ed3b0a84 h1:NlNEdePx7QY9Z4rds4EIe1dvUT8Ao1PZgLS80S5YTbU=
github.com/gomodule/oauth1 v0.0.0-20181215000758-9a59ed3b0a84/go.mod h1:4r/a8/3RkhMBxJQWL5qzbOEcaQmNPIkNoI7P8sXeI08=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.4.0 h1:XulKRWSQK5uChr4pEgSE4Tc/OcmnU9GJuSwdog/tZsA=
github.com/gorilla/handlers v1.4.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/lib/pq v0.0.0-20180523175426-90697d60dd84 h1:it29sI2IM490luSc3RAhp5WuCYnc6RtbfLVAB7nmC5M=
github.com/lib/pq v0.0.0-20180523175426-90697d60dd84/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lunny/html2md v0.0.0-20180317074532-13aaeeae9fb2 h1:eShptbR1fYhyKFFrjdSY1QuW6ymkTLlgyNEeZMchy3s=
github.com/lunny/html2md v0.0.0-20180317074532-13aaeeae9fb2/go.mod h1:lUUaVYlpAQ1Oo6vIZfec6CXQZjOvFZLyqaR8Dl7m+hk=
github.com/microcosm-cc/bluemonday v1.0.0 h1:dr58SIfmOwOVr+m4Ye1xLWv8Dk9OFwXAtYnbJSmJ65k=
github.com/microcosm-cc/bluemonday v1.0.0/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 h1:J1QZwDXgZ4dJD2s19iqR9+U00OWM2kDzbf1O/fmvCWg=
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/russross/blackfriday v1.5.1 h1:B8ZN6pD4PVofmlDCDUdELeYrbsVIDM/bpjW3v3zgcRc=
github.com/russross/blackfriday v1.5.1/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
golang.org/x/crypto v0.0.0-20180808211826-de0752318171 h1:vYogbvSFj2YXcjQxFHu/rASSOt9sLytpCaSkiwQ135I=
golang.org/x/crypto v0.0.0-20180808211826-de0752318171/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20180811021610-c39426892332 h1:efGso+ep0DjyCBJPjvoz0HI6UldX4Md2F1rZFe1ir0E=
golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc h1:3ElrZeO6IBP+M8kgu5YFwRo92Gqr+zBg3aooYQ6ziqU=
golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=

View File

@@ -10,8 +10,6 @@ func main() {
exitIfError(smtpTemplatesLoad())
exitIfError(oauthConfigure())
exitIfError(markdownRendererCreate())
exitIfError(emailNotificationPendingResetAll())
exitIfError(emailNotificationBegin())
exitIfError(sigintCleanupSetup())
exitIfError(versionCheckStart())
exitIfError(domainExportCleanupBegin())

View File

@@ -84,13 +84,11 @@ func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
email = user["email"].(string)
}
if user["name"] == nil {
fmt.Fprintf(w, "Error: no name returned by Github")
return
name := user["login"].(string)
if user["name"] != nil {
name = user["name"].(string)
}
name := user["name"].(string)
link := "undefined"
if user["html_url"] != nil {
link = user["html_url"].(string)
@@ -109,7 +107,6 @@ func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
var commenterHex string
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "github", "")
if err != nil {
@@ -117,6 +114,11 @@ func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
} else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "github"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}

View File

@@ -35,6 +35,8 @@ func gitlabOauthConfigure() error {
},
Endpoint: gitlab.Endpoint,
}
gitlabConfig.Endpoint.AuthURL = os.Getenv("GITLAB_URL") + "/oauth/authorize"
gitlabConfig.Endpoint.TokenURL = os.Getenv("GITLAB_URL") + "/oauth/token"
gitlabConfigured = true

View File

@@ -6,6 +6,7 @@ import (
"golang.org/x/oauth2"
"io/ioutil"
"net/http"
"os"
)
func gitlabCallbackHandler(w http.ResponseWriter, r *http.Request) {
@@ -24,7 +25,7 @@ func gitlabCallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
resp, err := http.Get("https://gitlab.com/api/v4/user?access_token=" + token.AccessToken)
resp, err := http.Get(os.Getenv("GITLAB_URL") + "/api/v4/user?access_token=" + token.AccessToken)
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
@@ -76,7 +77,6 @@ func gitlabCallbackHandler(w http.ResponseWriter, r *http.Request) {
var commenterHex string
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "gitlab", "")
if err != nil {
@@ -84,6 +84,11 @@ func gitlabCallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
} else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "gitlab"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}

View File

@@ -52,23 +52,32 @@ func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
name := user["name"].(string)
link := "undefined"
if user["link"] != nil {
link = user["link"].(string)
}
photo := "undefined"
if user["picture"] != nil {
photo = user["picture"].(string)
}
var commenterHex string
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
var link string
if val, ok := user["link"]; ok {
link = val.(string)
} else {
link = "undefined"
}
commenterHex, err = commenterNew(email, user["name"].(string), link, user["picture"].(string), "google", "")
commenterHex, err = commenterNew(email, name, link, photo, "google", "")
if err != nil {
fmt.Fprintf(w, "Error: %s", err.Error())
return
}
} else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "google"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}

View File

@@ -96,7 +96,6 @@ func ssoCallbackHandler(w http.ResponseWriter, r *http.Request) {
var commenterHex string
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(payload.Email, payload.Name, payload.Link, payload.Photo, "sso:"+domain, "")
if err != nil {
@@ -104,6 +103,11 @@ func ssoCallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
} else {
if err = commenterUpdate(c.CommenterHex, payload.Email, payload.Name, payload.Link, payload.Photo, "sso:"+domain); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
@@ -52,33 +53,20 @@ func twitterCallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
var res map[string]interface{}
var res twitterOAuthReponse
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
if res["email"] == nil {
fmt.Fprintf(w, "Error: no email address returned by Twitter")
if err := res.validate(); err != nil {
fmt.Fprintf(w, "Error: %s\n", err.Error())
return
}
email := res["email"].(string)
if res["name"] == nil {
fmt.Fprintf(w, "Error: no name returned by Twitter")
return
}
name := res["name"].(string)
link := "undefined"
photo := "undefined"
if res["handle"] != nil {
handle := res["screen_name"].(string)
link = "https://twitter.com/" + handle
photo = "https://twitter.com/" + handle + "/profile_image"
}
email := res.Email
name := res.Name
link := res.getLinkURL()
photo := res.getImageURL()
c, err := commenterGetByEmail("twitter", email)
if err != nil && err != errorNoSuchCommenter {
@@ -88,7 +76,6 @@ func twitterCallbackHandler(w http.ResponseWriter, r *http.Request) {
var commenterHex string
// TODO: in case of returning users, update the information we have on record?
if err == errorNoSuchCommenter {
commenterHex, err = commenterNew(email, name, link, photo, "twitter", "")
if err != nil {
@@ -96,6 +83,11 @@ func twitterCallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
} else {
if err = commenterUpdate(c.CommenterHex, email, name, link, photo, "twitter"); err != nil {
logger.Warningf("cannot update commenter: %s", err)
// not a serious enough to exit with an error
}
commenterHex = c.CommenterHex
}
@@ -106,3 +98,38 @@ func twitterCallbackHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
}
// response from Twitter API.
// ref: https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/user-object
type twitterOAuthReponse struct {
Email string `json:"email"`
Name string `json:"name"`
ScreenName string `json:"screen_name"`
// normal image size is 48x48.
// ref: https://developer.twitter.com/en/docs/accounts-and-users/user-profile-images-and-banners
ImageURL string `json:"profile_image_url_https"`
}
func (r twitterOAuthReponse) validate() error {
if r.Email == "" {
return errors.New("no email address returned by Twitter")
}
if r.Name == "" {
return errors.New("no name returned by Twitter")
}
return nil
}
func (r twitterOAuthReponse) getLinkURL() string {
if r.ScreenName == "" {
return "undefined"
}
return fmt.Sprintf("https://twitter.com/%s", r.ScreenName)
}
func (r twitterOAuthReponse) getImageURL() string {
if r.ImageURL == "" {
return "undefined"
}
return r.ImageURL
}

View File

@@ -11,9 +11,9 @@ func TestOwnerConfirmHexBasics(t *testing.T) {
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
statement := `
UPDATE owners
SET confirmedEmail=false;
`
UPDATE owners
SET confirmedEmail=false;
`
_, err := db.Exec(statement)
if err != nil {
t.Errorf("unexpected error when setting confirmedEmail=false: %v", err)
@@ -23,10 +23,10 @@ func TestOwnerConfirmHexBasics(t *testing.T) {
confirmHex, _ := randomHex(32)
statement = `
INSERT INTO
ownerConfirmHexes (confirmHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
INSERT INTO
ownerConfirmHexes (confirmHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
_, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC())
if err != nil {
t.Errorf("unexpected error creating inserting confirmHex: %v\n", err)
@@ -39,10 +39,10 @@ func TestOwnerConfirmHexBasics(t *testing.T) {
}
statement = `
SELECT confirmedEmail
FROM owners
WHERE ownerHex=$1;
`
SELECT confirmedEmail
FROM owners
WHERE ownerHex=$1;
`
row := db.QueryRow(statement, ownerHex)
var confirmedHex bool

79
api/owner_delete.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"net/http"
)
func ownerDelete(ownerHex string, deleteDomains bool) error {
domains, err := domainList(ownerHex)
if err != nil {
return err
}
if len(domains) > 0 {
if !deleteDomains {
return errorCannotDeleteOwnerWithActiveDomains
}
for _, d := range domains {
if err := domainDelete(d.Domain); err != nil {
return err
}
}
}
statement := `
DELETE FROM owners
WHERE ownerHex = $1;
`
_, err = db.Exec(statement, ownerHex)
if err != nil {
return errorNoSuchOwner
}
statement = `
DELETE FROM ownersessions
WHERE ownerHex = $1;
`
_, err = db.Exec(statement, ownerHex)
if err != nil {
logger.Errorf("cannot delete from ownersessions: %v", err)
return errorInternal
}
statement = `
DELETE FROM resethexes
WHERE hex = $1;
`
_, err = db.Exec(statement, ownerHex)
if err != nil {
logger.Errorf("cannot delete from resethexes: %v", err)
return errorInternal
}
return nil
}
func ownerDeleteHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
OwnerToken *string `json:"ownerToken"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
o, err := ownerGetByOwnerToken(*x.OwnerToken)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err = ownerDelete(o.OwnerHex, false); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -2,20 +2,38 @@ package main
import ()
var ownersRowColumns string = `
owners.ownerHex,
owners.email,
owners.name,
owners.confirmedEmail,
owners.joinDate
`
func ownersRowScan(s sqlScanner, o *owner) error {
return s.Scan(
&o.OwnerHex,
&o.Email,
&o.Name,
&o.ConfirmedEmail,
&o.JoinDate,
)
}
func ownerGetByEmail(email string) (owner, error) {
if email == "" {
return owner{}, errorMissingField
}
statement := `
SELECT ownerHex, email, name, confirmedEmail, joinDate
FROM owners
WHERE email=$1;
`
SELECT ` + ownersRowColumns + `
FROM owners
WHERE email=$1;
`
row := db.QueryRow(statement, email)
var o owner
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil {
if err := ownersRowScan(row, &o); err != nil {
// TODO: Make sure this is actually no such email.
return owner{}, errorNoSuchEmail
}
@@ -29,17 +47,17 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
}
statement := `
SELECT ownerHex, email, name, confirmedEmail, joinDate
SELECT ` + ownersRowColumns + `
FROM owners
WHERE ownerHex IN (
SELECT ownerHex FROM ownerSessions
WHERE ownerToken = $1
WHERE owners.ownerHex IN (
SELECT ownerSessions.ownerHex FROM ownerSessions
WHERE ownerSessions.ownerToken = $1
);
`
row := db.QueryRow(statement, ownerToken)
var o owner
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil {
if err := ownersRowScan(row, &o); err != nil {
logger.Errorf("cannot scan owner: %v\n", err)
return owner{}, errorInternal
}
@@ -53,14 +71,14 @@ func ownerGetByOwnerHex(ownerHex string) (owner, error) {
}
statement := `
SELECT ownerHex, email, name, confirmedEmail, joinDate
SELECT ` + ownersRowColumns + `
FROM owners
WHERE ownerHex = $1;
`
row := db.QueryRow(statement, ownerHex)
var o owner
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil {
if err := ownersRowScan(row, &o); err != nil {
logger.Errorf("cannot scan owner: %v\n", err)
return owner{}, errorInternal
}

View File

@@ -56,10 +56,10 @@ func ownerNew(email string, name string, password string) (string, error) {
}
statement = `
INSERT INTO
ownerConfirmHexes (confirmHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
INSERT INTO
ownerConfirmHexes (confirmHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
_, err = db.Exec(statement, confirmHex, ownerHex, time.Now().UTC())
if err != nil {
logger.Errorf("cannot insert confirmHex: %v\n", err)
@@ -92,10 +92,8 @@ func ownerNewHandler(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := commenterNew(*x.Email, *x.Name, "undefined", "undefined", "commento", *x.Password); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
// Errors in creating a commenter account should not hold this up.
_, _ = commenterNew(*x.Email, *x.Name, "undefined", "undefined", "commento", *x.Password)
bodyMarshal(w, response{"success": true, "confirmEmail": smtpConfigured})
}

View File

@@ -1,70 +0,0 @@
package main
import (
"net/http"
"time"
)
func ownerSendResetHex(email string) error {
if email == "" {
return errorMissingField
}
if !smtpConfigured {
return errorSmtpNotConfigured
}
o, err := ownerGetByEmail(email)
if err != nil {
if err == errorNoSuchEmail {
// TODO: use a more random time instead.
time.Sleep(1 * time.Second)
return nil
} else {
logger.Errorf("cannot get owner by email: %v", err)
return errorInternal
}
}
resetHex, err := randomHex(32)
if err != nil {
return err
}
statement := `
INSERT INTO
ownerResetHexes (resetHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
_, err = db.Exec(statement, resetHex, o.OwnerHex, time.Now().UTC())
if err != nil {
logger.Errorf("cannot insert resetHex: %v", err)
return errorInternal
}
err = smtpOwnerResetHex(email, o.Name, resetHex)
if err != nil {
return err
}
return nil
}
func ownerSendResetHexHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
Email *string `json:"email"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err := ownerSendResetHex(*x.Email); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -1,73 +0,0 @@
package main
import (
"golang.org/x/crypto/bcrypt"
"net/http"
)
func ownerResetPassword(resetHex string, password string) error {
if resetHex == "" || password == "" {
return errorMissingField
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logger.Errorf("cannot generate hash from password: %v\n", err)
return errorInternal
}
statement := `
UPDATE owners SET passwordHash=$1
WHERE ownerHex = (
SELECT ownerHex
FROM ownerResetHexes
WHERE resetHex=$2
);
`
res, err := db.Exec(statement, string(passwordHash), resetHex)
if err != nil {
logger.Errorf("cannot change user's password: %v\n", err)
return errorInternal
}
count, err := res.RowsAffected()
if err != nil {
logger.Errorf("cannot count rows affected: %v\n", err)
return errorInternal
}
if count == 0 {
return errorNoSuchResetToken
}
statement = `
DELETE FROM ownerResetHexes
WHERE resetHex=$1;
`
_, err = db.Exec(statement, resetHex)
if err != nil {
logger.Warningf("cannot remove reset token: %v\n", err)
}
return nil
}
func ownerResetPasswordHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
ResetHex *string `json:"resetHex"`
Password *string `json:"password"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
if err := ownerResetPassword(*x.ResetHex, *x.Password); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true})
}

View File

@@ -1,40 +0,0 @@
package main
import (
"testing"
"time"
)
func TestOwnerResetPasswordBasics(t *testing.T) {
failTestOnError(t, setupTestEnv())
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
resetHex, _ := randomHex(32)
statement := `
INSERT INTO
ownerResetHexes (resetHex, ownerHex, sendDate)
VALUES ($1, $2, $3 );
`
_, err := db.Exec(statement, resetHex, ownerHex, time.Now().UTC())
if err != nil {
t.Errorf("unexpected error inserting resetHex: %v", err)
return
}
if err = ownerResetPassword(resetHex, "hunter3"); err != nil {
t.Errorf("unexpected error resetting password: %v", err)
return
}
if _, err := ownerLogin("test@example.com", "hunter2"); err == nil {
t.Errorf("expected error not found when given old password")
return
}
if _, err := ownerLogin("test@example.com", "hunter3"); err != nil {
t.Errorf("unexpected error when logging in: %v", err)
return
}
}

82
api/reset.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"golang.org/x/crypto/bcrypt"
"net/http"
)
func reset(resetHex string, password string) (string, error) {
if resetHex == "" || password == "" {
return "", errorMissingField
}
statement := `
SELECT hex, entity
FROM resetHexes
WHERE resetHex = $1;
`
row := db.QueryRow(statement, resetHex)
var hex string
var entity string
if err := row.Scan(&hex, &entity); err != nil {
// TODO: is this the only error?
return "", errorNoSuchResetToken
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logger.Errorf("cannot generate hash from password: %v\n", err)
return "", errorInternal
}
if entity == "owner" {
statement = `
UPDATE owners SET passwordHash = $1
WHERE ownerHex = $2;
`
} else {
statement = `
UPDATE commenters SET passwordHash = $1
WHERE commenterHex = $2;
`
}
_, err = db.Exec(statement, string(passwordHash), hex)
if err != nil {
logger.Errorf("cannot change %s's password: %v\n", entity, err)
return "", errorInternal
}
statement = `
DELETE FROM resetHexes
WHERE resetHex = $1;
`
_, err = db.Exec(statement, resetHex)
if err != nil {
logger.Warningf("cannot remove resetHex: %v\n", err)
}
return entity, nil
}
func resetHandler(w http.ResponseWriter, r *http.Request) {
type request struct {
ResetHex *string `json:"resetHex"`
Password *string `json:"password"`
}
var x request
if err := bodyUnmarshal(r, &x); err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
entity, err := reset(*x.ResetHex, *x.Password)
if err != nil {
bodyMarshal(w, response{"success": false, "message": err.Error()})
return
}
bodyMarshal(w, response{"success": true, "entity": entity})
}

View File

@@ -8,9 +8,8 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST")
router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET")
router.HandleFunc("/api/owner/login", ownerLoginHandler).Methods("POST")
router.HandleFunc("/api/owner/send-reset-hex", ownerSendResetHexHandler).Methods("POST")
router.HandleFunc("/api/owner/reset-password", ownerResetPasswordHandler).Methods("POST")
router.HandleFunc("/api/owner/self", ownerSelfHandler).Methods("POST")
router.HandleFunc("/api/owner/delete", ownerDeleteHandler).Methods("POST")
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST")
@@ -22,6 +21,7 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST")
router.HandleFunc("/api/domain/import/disqus", domainImportDisqusHandler).Methods("POST")
router.HandleFunc("/api/domain/import/commento", domainImportCommentoHandler).Methods("POST")
router.HandleFunc("/api/domain/export/begin", domainExportBeginHandler).Methods("POST")
router.HandleFunc("/api/domain/export/download", domainExportDownloadHandler).Methods("GET")
@@ -29,8 +29,12 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
router.HandleFunc("/api/commenter/update", commenterUpdateHandler).Methods("POST")
router.HandleFunc("/api/commenter/photo", commenterPhotoHandler).Methods("GET")
router.HandleFunc("/api/forgot", forgotHandler).Methods("POST")
router.HandleFunc("/api/reset", resetHandler).Methods("POST")
router.HandleFunc("/api/email/get", emailGetHandler).Methods("POST")
router.HandleFunc("/api/email/update", emailUpdateHandler).Methods("POST")
router.HandleFunc("/api/email/moderate", emailModerateHandler).Methods("GET")
@@ -51,6 +55,7 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/oauth/sso/callback", ssoCallbackHandler).Methods("GET")
router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST")
router.HandleFunc("/api/comment/edit", commentEditHandler).Methods("POST")
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")
router.HandleFunc("/api/comment/vote", commentVoteHandler).Methods("POST")

View File

@@ -96,12 +96,14 @@ func staticRouterInit(router *mux.Router) error {
pages := []string{
"/login",
"/forgot",
"/reset-password",
"/reset",
"/signup",
"/confirm-email",
"/unsubscribe",
"/dashboard",
"/settings",
"/logout",
"/profile",
}
for _, page := range pages {

View File

@@ -19,10 +19,6 @@ func smtpConfigure() error {
return nil
}
if username == "" || password == "" {
logger.Warningf("no SMTP username/password set, Commento will assume they aren't required")
}
if os.Getenv("SMTP_FROM_ADDRESS") == "" {
logger.Errorf("COMMENTO_SMTP_FROM_ADDRESS not set")
smtpConfigured = false
@@ -30,7 +26,11 @@ func smtpConfigure() error {
}
logger.Infof("configuring smtp: %s", host)
smtpAuth = smtp.PlainAuth("", username, password, host)
if username == "" || password == "" {
logger.Warningf("no SMTP username/password set, Commento will assume they aren't required")
} else {
smtpAuth = smtp.PlainAuth("", username, password, host)
}
smtpConfigured = true
return nil
}

View File

@@ -9,43 +9,19 @@ import (
tt "text/template"
)
type emailNotificationText struct {
emailNotification
Html ht.HTML
}
type emailNotificationPlugs struct {
Origin string
Kind string
Subject string
UnsubscribeSecretHex string
Notifications []emailNotificationText
Domain string
Path string
CommentHex string
CommenterName string
Title string
Html ht.HTML
}
func smtpEmailNotification(to string, toName string, unsubscribeSecretHex string, notifications []emailNotificationText, kind string) error {
var subject string
if kind == "reply" {
var verb string
if len(notifications) > 1 {
verb = "replies"
} else {
verb = "reply"
}
subject = fmt.Sprintf("%d new comment %s", len(notifications), verb)
} else {
var verb string
if len(notifications) > 1 {
verb = "comments"
} else {
verb = "comment"
}
if kind == "pending-moderation" {
subject = fmt.Sprintf("%d new %s pending moderation", len(notifications), verb)
} else {
subject = fmt.Sprintf("%d new %s on your website", len(notifications), verb)
}
}
func smtpEmailNotification(to string, toName string, kind string, domain string, path string, commentHex string, commenterName string, title string, html string, unsubscribeSecretHex string) error {
h, err := tt.New("header").Parse(`MIME-Version: 1.0
From: Commento <{{.FromAddress}}>
To: {{.ToName}} <{{.ToAddress}}>
@@ -53,9 +29,8 @@ Content-Type: text/html; charset=UTF-8
Subject: {{.Subject}}
`)
var header bytes.Buffer
h.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "[Commento] " + subject})
h.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "[Commento] " + title})
t, err := ht.ParseFiles(fmt.Sprintf("%s/templates/email-notification.txt", os.Getenv("STATIC")))
if err != nil {
@@ -67,9 +42,13 @@ Subject: {{.Subject}}
err = t.Execute(&body, &emailNotificationPlugs{
Origin: os.Getenv("ORIGIN"),
Kind: kind,
Subject: subject,
Domain: domain,
Path: path,
CommentHex: commentHex,
CommenterName: commenterName,
Title: title,
Html: ht.HTML(html),
UnsubscribeSecretHex: unsubscribeSecretHex,
Notifications: notifications,
})
if err != nil {
logger.Errorf("error generating templated HTML for email notification: %v", err)

View File

@@ -6,17 +6,17 @@ import (
"os"
)
type ownerResetHexPlugs struct {
type resetHexPlugs struct {
Origin string
ResetHex string
}
func smtpOwnerResetHex(to string, toName string, resetHex string) error {
func smtpResetHex(to string, toName string, resetHex string) error {
var header bytes.Buffer
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"})
var body bytes.Buffer
templates["reset-hex"].Execute(&body, &ownerResetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex})
templates["reset-hex"].Execute(&body, &resetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex})
err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
if err != nil {

View File

@@ -2,9 +2,10 @@ package main
import (
"fmt"
"github.com/op/go-logging"
"os"
"testing"
"github.com/op/go-logging"
)
func failTestOnError(t *testing.T, err error) {
@@ -15,10 +16,10 @@ func failTestOnError(t *testing.T, err error) {
func getPublicTables() ([]string, error) {
statement := `
SELECT tablename
FROM pg_tables
WHERE schemaname='public';
`
SELECT tablename
FROM pg_tables
WHERE schemaname='public';
`
rows, err := db.Query(statement)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot query public tables: %v", err)

View File

@@ -10,16 +10,6 @@ func concat(a bytes.Buffer, b bytes.Buffer) []byte {
return append(a.Bytes(), b.Bytes()...)
}
func nameFromEmail(email string) string {
for i, c := range email {
if c == '@' {
return email[:i]
}
}
return email
}
func exitIfError(err error) {
if err != nil {
fmt.Printf("fatal error: %v\n", err)

View File

@@ -18,13 +18,13 @@ func emailStrip(email string) string {
}
var https = regexp.MustCompile(`(https?://)`)
var trailingSlash = regexp.MustCompile(`(/*$)`)
var domainTrail = regexp.MustCompile(`(/.*$)`)
func domainStrip(domain string) string {
noSlash := trailingSlash.ReplaceAllString(domain, ``)
noProtocol := https.ReplaceAllString(noSlash, ``)
noProtocol := https.ReplaceAllString(domain, ``)
noTrail := domainTrail.ReplaceAllString(noProtocol, ``)
return noProtocol
return noTrail
}
var pathMatch = regexp.MustCompile(`(https?://[^/]*)`)

11
api/utils_sql.go Normal file
View File

@@ -0,0 +1,11 @@
package main
import ()
// scanner is a database/sql abstraction interface that can be used with both
// *sql.Row and *sql.Rows.
type sqlScanner interface {
// Scan copies columns from the underlying query row(s) to the values
// pointed to by dest.
Scan(dest ...interface{}) error
}

View File

@@ -0,0 +1,13 @@
-- This trigger is called every time a comment is deleted, so the comment count for the page where the comment belong is updated
CREATE OR REPLACE FUNCTION commentsDeleteTriggerFunction() RETURNS TRIGGER AS $trigger$
BEGIN
UPDATE pages
SET commentCount = commentCount - 1
WHERE domain = old.domain AND path = old.path;
DELETE FROM comments
WHERE parentHex = old.commentHex;
RETURN NEW;
END;
$trigger$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,2 @@
UPDATE pages
SET commentCount = commentCount + 1;

View File

@@ -0,0 +1,8 @@
-- Create the resetHexes table
ALTER TABLE ownerResetHexes RENAME TO resetHexes;
ALTER TABLE resetHexes RENAME ownerHex TO hex;
ALTER TABLE resetHexes
ADD entity TEXT NOT NULL DEFAULT 'owner';

View File

@@ -0,0 +1,6 @@
DROP TRIGGER IF EXISTS commentsDeleteTrigger ON comments;
DROP FUNCTION IF EXISTS commentsDeleteTriggerFunction();
ALTER TABLE comments
ADD deleted BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,10 @@
-- Default sort policy for each domain
CREATE TYPE sortPolicy AS ENUM (
'score-desc',
'creationdate-desc',
'creationdate-asc'
);
ALTER TABLE domains
ADD defaultSortPolicy sortPolicy NOT NULL DEFAULT 'score-desc';

View File

@@ -0,0 +1,2 @@
ALTER TABLE comments ADD deleterHex TEXT;
ALTER TABLE comments ADD deletionDate TIMESTAMP;

View File

@@ -14,6 +14,7 @@
<div id="navbar" class="navbar">
<a href="[[[.Origin]]]/" class="navbar-item navbar-logo-text"><img src="[[[.CdnPrefix]]]/images/logo.svg" class="navbar-logo">Commento</a>
<div class="navbar-item">
<a href="[[[.Origin]]]/settings" class="navbar-item">Settings</a>
<a href="[[[.Origin]]]/logout" class="navbar-item">Logout</a>
<div class="float-right"><b><div id="owner-name"></div></b></div>
</div>
@@ -102,7 +103,7 @@
<br>
<div class="normal-text">
Read the Commento documentation <a href="https://docs.commento.io/configuration/">on configuration</a>.
Read the Commento documentation <a href="https://docs.commento.io/configuration/frontend/">on configuration</a>.
</div>
</div>
</div>
@@ -204,6 +205,26 @@
</div>
</div>
<div class="question">
<div class="title">
Comment Sorting
</div>
<div class="answer">
<div class="row no-border commento-round-check">
<input type="radio" id="defaultSortPolicy-score-desc" value="score-desc" v-model="domains[cd].defaultSortPolicy">
<label for="defaultSortPolicy-score-desc">Most upvoted first</label>
</div>
<div class="row no-border commento-round-check">
<input type="radio" id="defaultSortPolicy-creationdate-desc" value="creationdate-desc" v-model="domains[cd].defaultSortPolicy">
<label for="defaultSortPolicy-creationdate-desc">Newest first</label>
</div>
<div class="row no-border commento-round-check">
<input type="radio" id="defaultSortPolicy-creationdate-asc" value="creationdate-asc" v-model="domains[cd].defaultSortPolicy">
<label for="defaultSortPolicy-creationdate-asc">Oldest first</label>
</div>
</div>
</div>
<div class="center">
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
</div>
@@ -314,12 +335,32 @@
</div>
</div>
<div class="warning" v-if="!domains[cd].allowAnonymous && !domains[cd].commentoProvider && !domains[cd].googleProvider && !domains[cd].twitterProvider && !domains[cd].githubProvider && !domains[cd].gitlabProvider">
<div class="warning" v-if="!domains[cd].allowAnonymous && !domains[cd].commentoProvider && (!configuredOauths.google || !domains[cd].googleProvider) && (!configuredOauths.twitter || !domains[cd].twitterProvider) && (!configuredOauths.github || !domains[cd].githubProvider) && (!configuredOauths.gitlab || !domains[cd].gitlabProvider) && !domains[cd].ssoProvider">
You have disabled all authentication options. Your readers will not be able to login, create comments, or vote.
</div>
</div>
</div>
<div class="question">
<div class="title">
Default Comment Sorting
</div>
<div class="answer">
<div class="row no-border commento-round-check">
<input type="radio" id="defaultSortPolicy-score-desc" value="score-desc" v-model="domains[cd].defaultSortPolicy">
<label for="defaultSortPolicy-score-desc">Most upvoted first</label>
</div>
<div class="row no-border commento-round-check">
<input type="radio" id="defaultSortPolicy-creationdate-desc" value="creationdate-desc" v-model="domains[cd].defaultSortPolicy">
<label for="defaultSortPolicy-creationdate-desc">Newest first</label>
</div>
<div class="row no-border commento-round-check">
<input type="radio" id="defaultSortPolicy-creationdate-asc" value="creationdate-asc" v-model="domains[cd].defaultSortPolicy">
<label for="defaultSortPolicy-creationdate-asc">Oldest first</label>
</div>
</div>
</div>
<div class="center">
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
</div>
@@ -349,10 +390,11 @@
<div class="tabs-container">
<div class="tab">
<ul class="tabs">
<li class="tab-link original current" data-tab="install-tab-1">Disqus</li>
<li class="tab-link original current" data-tab="import-tab-1">Disqus</li>
<li class="tab-link" data-tab="import-tab-2">Commento</li>
</ul>
<div id="install-tab-1" class="content original current">
<div id="import-tab-1" class="content original current">
<div class="normal-text">
If you're currently using Disqus, you can import all comments into Commento:
<ul>
@@ -392,6 +434,41 @@
</ul>
</div>
</div>
<div id="import-tab-2" class="content">
<div class="normal-text">
If you've previously exported data from Commento you can restore it:
<ul>
<li>
Upload your exported data file somewhere in the cloud and generate a direct link to it. Ensure that the export file is a GZIP archive of a JSON file.
</li>
<li>
Copy and paste that link here to start the import process:
<br><br>
<div class="commento-email-container">
<div class="commento-email">
<input class="commento-input" type="text" id="commento-url" placeholder="https://example.com/commento.json.gz">
<button id="commento-import-button" class="commento-email-button" onclick="window.commento.importCommento()">Import</button>
</div>
</div>
<br>
</li>
<li>
Commento will automatically download this file, extract it, parse it and import comments into Commento. URL information, comment authors, text formatting, and nested replies will be preserved.
</li>
<li>
It is strongly recommended you do this only once. Importing multiple times may have unintended effects.
</li>
</ul>
</div>
</div>
</div>
</div>
</div>

View File

@@ -70,6 +70,14 @@ const jsCompileMap = {
"js/dashboard-danger.js",
"js/dashboard-export.js",
],
"js/settings.js": [
"js/constants.js",
"js/utils.js",
"js/http.js",
"js/errors.js",
"js/self.js",
"js/settings.js"
],
"js/logout.js": [
"js/constants.js",
"js/utils.js",
@@ -83,50 +91,66 @@ const jsCompileMap = {
"js/http.js",
"js/unsubscribe.js",
],
"js/profile.js": [
"js/constants.js",
"js/utils.js",
"js/http.js",
"js/profile.js",
],
};
gulp.task("scss-devel", function () {
return gulp.src(scssSrc)
gulp.task("scss-devel", function (done) {
let res = gulp.src(scssSrc)
.pipe(sourcemaps.init())
.pipe(sass({outputStyle: "expanded"}).on("error", sass.logError))
.pipe(sourcemaps.write())
.pipe(gulp.dest(develPath + cssDir));
done();
return res;
});
gulp.task("scss-prod", function () {
return gulp.src(scssSrc)
gulp.task("scss-prod", function (done) {
let res = gulp.src(scssSrc)
.pipe(sass({outputStyle: "compressed"}).on("error", sass.logError))
.pipe(cleanCss({compatibility: "ie8", level: 2}))
.pipe(gulp.dest(prodPath + cssDir));
done();
return res;
});
gulp.task("html-devel", function () {
gulp.task("html-devel", function (done) {
gulp.src([htmlGlob]).pipe(gulp.dest(develPath));
done();
});
gulp.task("html-prod", function () {
gulp.task("html-prod", function (done) {
gulp.src(htmlGlob)
.pipe(htmlMinifier({collapseWhitespace: true, removeComments: true}))
.pipe(gulp.dest(prodPath))
done();
});
gulp.task("fonts-devel", function () {
gulp.task("fonts-devel", function (done) {
gulp.src([fontsGlob]).pipe(gulp.dest(develPath + fontsDir));
done();
});
gulp.task("fonts-prod", function () {
gulp.task("fonts-prod", function (done) {
gulp.src([fontsGlob]).pipe(gulp.dest(prodPath + fontsDir));
done();
});
gulp.task("images-devel", function () {
gulp.task("images-devel", function (done) {
gulp.src([imagesGlob]).pipe(gulp.dest(develPath + imagesDir));
done();
});
gulp.task("images-prod", function () {
gulp.task("images-prod", function (done) {
gulp.src([imagesGlob]).pipe(gulp.dest(prodPath + imagesDir));
done();
});
gulp.task("js-devel", function () {
gulp.task("js-devel", function (done) {
for (let outputFile in jsCompileMap) {
gulp.src(jsCompileMap[outputFile])
.pipe(sourcemaps.init())
@@ -135,9 +159,10 @@ gulp.task("js-devel", function () {
.pipe(sourcemaps.write())
.pipe(gulp.dest(develPath))
}
done();
});
gulp.task("js-prod", function () {
gulp.task("js-prod", function (done) {
for (let outputFile in jsCompileMap) {
gulp.src(jsCompileMap[outputFile])
.pipe(concat(outputFile))
@@ -145,13 +170,16 @@ gulp.task("js-prod", function () {
.pipe(uglify())
.pipe(gulp.dest(prodPath))
}
done();
});
gulp.task("lint", function () {
return gulp.src(jsGlob)
gulp.task("lint", function (done) {
let res = gulp.src(jsGlob)
.pipe(eslint())
.pipe(eslint.failAfterError())
.pipe(eslint.failAfterError());
done();
return res;
});
gulp.task("devel", ["scss-devel", "html-devel", "fonts-devel", "images-devel", "lint", "js-devel"]);
gulp.task("prod", ["scss-prod", "html-prod", "fonts-prod", "images-prod", "lint", "js-prod"]);
gulp.task("devel", gulp.parallel("scss-devel", "html-devel", "fonts-devel", "images-devel", "lint", "js-devel"));
gulp.task("prod", gulp.parallel("scss-prod", "html-prod", "fonts-prod", "images-prod", "lint", "js-prod"));

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,42 @@
xmlDoc.send(JSON.stringify(data));
}
var commentsText = function(count) {
return count + " " + (count === 1 ? "comment" : "comments")
}
function tags(tag) {
return document.getElementsByTagName(tag);
}
function attrGet(node, a) {
var attr = node.attributes[a];
if (attr === undefined) {
return undefined;
}
return attr.value;
}
function dataTagsLoad() {
var scripts = tags("script")
for (var i = 0; i < scripts.length; i++) {
if (scripts[i].src.match(/\/js\/count\.js$/)) {
var customCommentsText = attrGet(scripts[i], "data-custom-text");
if (customCommentsText !== undefined) {
commentsText = eval(customCommentsText);
}
}
}
}
function main() {
var paths = [];
var doms = [];
dataTagsLoad();
var as = document.getElementsByTagName("a");
for (var i = 0; i < as.length; i++) {
var href = as[i].href;
@@ -28,12 +61,15 @@
href = href.replace(/^.*\/\/[^\/]+/, "");
if (href.endsWith("#commento")) {
var path = href.substr(0, href.indexOf("#commento"));
if (path.startsWith(parent.location.host)) {
path = path.substr(parent.location.host.length);
var pageId = attrGet(as[i], "data-page-id");
if (pageId === undefined) {
pageId = href.substr(0, href.indexOf("#commento"));
if (pageId.startsWith(parent.location.host)) {
pageId = pageId.substr(parent.location.host.length);
}
}
paths.push(path);
paths.push(pageId);
doms.push(as[i]);
}
}
@@ -55,7 +91,7 @@
count = resp.commentCounts[paths[i]];
}
doms[i].innerText = count + " " + (count === 1 ? "comment" : "comments");
doms[i].innerText = commentsText(count);
}
});
}

View File

@@ -34,4 +34,29 @@
});
}
global.importCommento = function() {
var url = $("#commento-url").val();
var data = global.dashboard.$data;
var json = {
"ownerToken": global.cookieGet("commentoOwnerToken"),
"domain": data.domains[data.cd].domain,
"url": url,
}
global.buttonDisable("#commento-import-button");
global.post(global.origin + "/api/domain/import/commento", json, function(resp) {
global.buttonEnable("#commento-import-button");
if (!resp.success) {
global.globalErrorShow(resp.message);
return;
}
$("#commento-import-button").hide();
global.globalOKShow("Imported " + resp.numImported + " comments!");
});
}
} (window.commento, document));

View File

@@ -6,8 +6,8 @@
// Opens the installation view.
global.installationOpen = function() {
var html = "" +
"<script defer src=\"" + global.cdn + "/js/commento.js\"><\/script>\n" +
"<div id=\"commento\"></div>\n" +
"<script src=\"" + global.cdn + "/js/commento.js\"><\/script>\n" +
"";
$("#code-div").text(html);

View File

@@ -16,12 +16,18 @@
return;
}
var entity = "owner";
if (global.paramGet("commenter") === "true") {
entity = "commenter";
}
var json = {
"email": $("#email").val(),
"entity": entity,
};
global.buttonDisable("#reset-button");
global.post(global.origin + "/api/owner/send-reset-hex", json, function(resp) {
global.post(global.origin + "/api/forgot", json, function(resp) {
global.buttonEnable("#reset-button");
global.textSet("#err", "");

View File

@@ -33,12 +33,22 @@
}
}
// Shows messages produced from account deletion.
function displayDeletedOwner() {
var deleted = global.paramGet("deleted");
if (deleted === "true") {
$("#msg").html("Your account has been deleted.")
}
}
// Shows email confirmation and password reset messages, if any.
global.displayMessages = function() {
displayConfirmedEmail();
displayChangedPassword();
displaySignedUp();
displayDeletedOwner();
};

76
frontend/js/profile.js Normal file
View File

@@ -0,0 +1,76 @@
(function (global, document) {
"use strict";
(document);
// Update the email records.
global.update = function(event) {
event.preventDefault();
$(".err").text("");
$(".msg").text("");
var allOk = global.unfilledMark(["#name", "#email"], function(el) {
el.css("border-bottom", "1px solid red");
});
if (!allOk) {
global.textSet("#err", "Please make sure all fields are filled");
return;
}
var json = {
"commenterToken": global.paramGet("commenterToken"),
"name": $("#name").val(),
"email": $("#email").val(),
"link": $("#link").val(),
"photo": $("#photo").val(),
};
global.buttonDisable("#save-button");
global.post(global.origin + "/api/commenter/update", json, function(resp) {
global.buttonEnable("#save-button");
if (!resp.success) {
$(".err").text(resp.message);
return;
}
$(".msg").text("Successfully updated!");
});
}
global.profilePrefill = function() {
$(".err").text("");
$(".msg").text("");
var json = {
"commenterToken": global.paramGet("commenterToken"),
};
global.post(global.origin + "/api/commenter/self", json, function(resp) {
$("#loading").hide();
$("#form").show();
if (!resp.success) {
$(".err").text(resp.message);
return;
}
$("#name").val(resp.commenter.name);
$("#email").val(resp.commenter.email);
$("#unsubscribe").attr("href", global.origin + "/unsubscribe?unsubscribeSecretHex=" + resp.email.unsubscribeSecretHex);
if (resp.commenter.provider === "commento") {
$("#link-row").attr("style", "")
if (resp.commenter.link !== "undefined") {
$("#link").val(resp.commenter.link);
}
$("#photo-row").attr("style", "")
$("#photo-subtitle").attr("style", "")
if (resp.commenter.photo !== "undefined") {
$("#photo").val(resp.commenter.photo);
}
}
});
};
} (window.commento, document));

View File

@@ -24,7 +24,7 @@
};
global.buttonDisable("#reset-button");
global.post(global.origin + "/api/owner/reset-password", json, function(resp) {
global.post(global.origin + "/api/reset", json, function(resp) {
global.buttonEnable("#reset-button");
global.textSet("#err", "");
@@ -33,7 +33,11 @@
return
}
document.location = global.origin + "/login?changed=true";
if (resp.entity === "owner") {
document.location = global.origin + "/login?changed=true";
} else {
$("#msg").html("Your password has been reset. You may close this window and try logging in again.");
}
});
}

54
frontend/js/settings.js Normal file
View File

@@ -0,0 +1,54 @@
(function (global, document) {
"use strict";
(document);
global.vueConstruct = function(callback) {
var reactiveData = {
hasSource: global.owner.hasSource,
lastFour: global.owner.lastFour,
};
global.settings = new Vue({
el: "#settings",
data: reactiveData,
});
if (callback !== undefined) {
callback();
}
};
global.settingShow = function(setting) {
$(".pane-setting").removeClass("selected");
$(".view").hide();
$("#" + setting).addClass("selected");
$("#" + setting + "-view").show();
};
global.deleteOwnerHandler = function() {
if (!confirm("Are you absolutely sure you want to delete your account?")) {
return;
}
var json = {
"ownerToken": global.cookieGet("commentoOwnerToken"),
}
$("#delete-owner-button").prop("disabled", true);
$("#delete-owner-button").text("Deleting...");
global.post(global.origin + "/api/owner/delete", json, function(resp) {
if (!resp.success) {
$("#delete-owner-button").prop("disabled", false);
$("#delete-owner-button").text("Delete Account");
global.globalErrorShow(resp.message);
$("#error-message").text(resp.message);
return;
}
global.cookieDelete("commentoOwnerToken");
document.location = global.origin + "/login?deleted=true";
});
};
} (window.commento, document));

View File

@@ -75,7 +75,7 @@
expires = "; expires=" + date.toUTCString();
var cookieString = name + "=" + value + expires + "; path=/";
if (/^https:\/\//i.test(origin)) {
if (/^https:\/\//i.test(global.origin)) {
cookieString += "; secure";
}

View File

@@ -9,7 +9,7 @@
"devDependencies": {
"chartist": "0.11.0",
"fixmyjs": "2.0.0",
"gulp": "3.9.1",
"gulp": "4.0.2",
"gulp-clean-css": "3.9.4",
"gulp-concat": "2.6.1",
"gulp-eslint": "5.0.0",
@@ -21,7 +21,6 @@
"highlightjs": "9.10.0",
"html-minifier": "3.5.7",
"jquery": "3.2.1",
"natives": "^1.1.6",
"normalize-scss": "7.0.1",
"sass": "1.5.1",
"uglify-js": "3.4.1",

66
frontend/profile.html Normal file
View File

@@ -0,0 +1,66 @@
<html>
<head>
<meta name="viewport" content="user-scalable=no, initial-scale=1.0">
<script src="[[[.CdnPrefix]]]/js/jquery.js"></script>
<script src="[[[.CdnPrefix]]]/js/profile.js"></script>
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/auth.css">
<title>Commento: Edit Profile</title>
</head>
<div class="navbar">
<a href="[[[.Origin]]]/" class="navbar-item navbar-logo-text"><img src="[[[.CdnPrefix]]]/images/logo.svg" class="navbar-logo">Commento</a>
</div>
<script>
window.onload = function() {
window.commento.profilePrefill();
};
</script>
<div class="auth-form-container">
<div class="auth-form">
<div id="loading">
Loading...
</div>
<form onsubmit="window.commento.update(event)" id="form" style="display: none">
<div class="form-title">
Edit Profile
</div>
<div class="row">
<div class="label">Name</div>
<input class="input" type="text" name="name" id="name" placeholder="Name">
</div>
<div class="row no-margin-bottom-row">
<div class="label">Email Address</div>
<input class="input" type="text" name="email" id="email" placeholder="example@example.com" disabled>
</div>
<div class="small-subtitle">
Since your identity is directly tied to your email address, changing your email address is not possible. <a href="" id="unsubscribe">Want to change your notification settings?</a>
</div>
<div id="link-row" class="row" style="display: none">
<div class="label">Website (Optional)</div>
<input class="input" type="text" name="link" id="link" placeholder="https://example.com">
</div>
<div id="photo-row" class="row no-margin-bottom-row" style="display: none">
<div class="label">Photo</div>
<input class="input" type="text" name="photo" id="photo" placeholder="https://i.imgur.com/BCKlYFQ.jpg">
</div>
<div class="small-subtitle" style="display: none" id="photo-subtitle">
Use an external image hosting service such as <a href="https://imgur.com" rel="nofollow">Imgur</a> and enter the direct link to the image here. Changes to your profile photo may take a few hours to reflect. Maximum file size allowed is 128 KiB.
</div>
<div class="err" id="err"></div>
<div class="msg" id="msg"></div>
<button id="button" class="button" type="submit">Update Profile</button>
</form>
</div>
</div>
[[[.Footer]]]
</html>

View File

@@ -36,11 +36,17 @@ body {
padding-bottom: 24px;
}
.row {
padding-left: 8px;
padding-right: 8px;
margin-bottom: 20px;
border-bottom: 1px solid $gray-1;
.small-subtitle {
font-size: 12px;
color: $gray-6;
padding: 0px 12px 20px 12px;
}
.row {
padding-left: 8px;
padding-right: 8px;
margin-bottom: 20px;
border-bottom: 1px solid $gray-1;
input[type=text],
input[type=password] {
@@ -74,6 +80,10 @@ body {
}
}
.no-margin-bottom-row {
margin-bottom: 6px;
}
.button {
@extend .shadow;
border: 1px solid $blue-6;

View File

@@ -3,6 +3,7 @@
code {
font-family: monospace;
font-size: 13px;
white-space: pre;
}
a {

View File

@@ -21,12 +21,12 @@
position: relative;
height: 38px;
.commento-logout {
.commento-profile-button {
float: right;
cursor: pointer;
position: absolute;
top: 6px;
right: 16px;
color: $gray-5;
color: $gray-6;
margin: 6px 12px;
font-size: 13px;
}
.commento-logged-in-as {

View File

@@ -38,16 +38,24 @@
@import "email-main.scss";
.commento-forgot-link-container,
.commento-login-link-container {
margin: 16px;
width: calc(100% - 32px);
text-align: center;
}
.commento-login-link {
font-size: 14px;
font-weight: bold;
border-bottom: none;
}
.commento-forgot-link,
.commento-login-link {
font-size: 14px;
font-weight: bold;
border-bottom: none;
}
.commento-forgot-link {
font-size: 13px;
color: $gray-6;
font-weight: normal;
}
.commento-login-box-close {

View File

@@ -67,10 +67,18 @@
background: $indigo-6;
}
.commento-option-edit {
height: 14px;
width: 14px;
@include mask-image('data:image/svg+xml;utf8,<?xml version="1.0" encoding="UTF-8"?><svg enable-background="new 0 0 528.899 528.899" version="1.1" viewBox="0 0 528.899 528.899" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m328.88 89.125l107.59 107.59-272.34 272.34-107.53-107.59 272.28-272.34zm189.23-25.948l-47.981-47.981c-18.543-18.543-48.653-18.543-67.259 0l-45.961 45.961 107.59 107.59 53.611-53.611c14.382-14.383 14.382-37.577 0-51.959zm-517.81 449.51c-1.958 8.812 5.998 16.708 14.811 14.565l119.89-29.069-107.53-107.59-27.173 122.09z"/></svg>');
margin: 12px 6px 12px 6px;
background: $gray-5;
}
.commento-option-remove {
height: 14px;
width: 14px;
@include mask-image('data:image/svg+xml;utf8,<?xml version="1.0" encoding="UTF-8"?><svg enable-background="new 0 0 59 59" version="1.1" viewBox="0 0 59 59" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="%231e2127"> <path d="m29.5 51c0.552 0 1-0.447 1-1v-33c0-0.553-0.448-1-1-1s-1 0.447-1 1v33c0 0.553 0.448 1 1 1z"/> <path d="m19.5 51c0.552 0 1-0.447 1-1v-33c0-0.553-0.448-1-1-1s-1 0.447-1 1v33c0 0.553 0.448 1 1 1z"/> <path d="m39.5 51c0.552 0 1-0.447 1-1v-33c0-0.553-0.448-1-1-1s-1 0.447-1 1v33c0 0.553 0.448 1 1 1z"/> <path d="M52.5,6H38.456c-0.11-1.25-0.495-3.358-1.813-4.711C35.809,0.434,34.751,0,33.499,0H23.5c-1.252,0-2.31,0.434-3.144,1.289 C19.038,2.642,18.653,4.75,18.543,6H6.5c-0.552,0-1,0.447-1,1s0.448,1,1,1h2.041l1.915,46.021C10.493,55.743,11.565,59,15.364,59 h28.272c3.799,0,4.871-3.257,4.907-4.958L50.459,8H52.5c0.552,0,1-0.447,1-1S53.052,6,52.5,6z M21.792,2.681 C22.24,2.223,22.799,2,23.5,2h9.999c0.701,0,1.26,0.223,1.708,0.681c0.805,0.823,1.128,2.271,1.24,3.319H20.553 C20.665,4.952,20.988,3.504,21.792,2.681z M46.544,53.979C46.538,54.288,46.4,57,43.636,57H15.364 c-2.734,0-2.898-2.717-2.909-3.042L10.542,8h37.915L46.544,53.979z"/></g></svg>');
@include mask-image('data:image/svg+xml;utf8,<?xml version="1.0" encoding="UTF-8"?><svg enable-background="new 0 0 59 59" version="1.1" viewBox="0 0 59 59" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="%231e2127"><path d="m29.5 51c0.552 0 1-0.447 1-1v-33c0-0.553-0.448-1-1-1s-1 0.447-1 1v33c0 0.553 0.448 1 1 1z"/><path d="m19.5 51c0.552 0 1-0.447 1-1v-33c0-0.553-0.448-1-1-1s-1 0.447-1 1v33c0 0.553 0.448 1 1 1z"/><path d="m39.5 51c0.552 0 1-0.447 1-1v-33c0-0.553-0.448-1-1-1s-1 0.447-1 1v33c0 0.553 0.448 1 1 1z"/><path d="M52.5,6H38.456c-0.11-1.25-0.495-3.358-1.813-4.711C35.809,0.434,34.751,0,33.499,0H23.5c-1.252,0-2.31,0.434-3.144,1.289 C19.038,2.642,18.653,4.75,18.543,6H6.5c-0.552,0-1,0.447-1,1s0.448,1,1,1h2.041l1.915,46.021C10.493,55.743,11.565,59,15.364,59 h28.272c3.799,0,4.871-3.257,4.907-4.958L50.459,8H52.5c0.552,0,1-0.447,1-1S53.052,6,52.5,6z M21.792,2.681 C22.24,2.223,22.799,2,23.5,2h9.999c0.701,0,1.26,0.223,1.708,0.681c0.805,0.823,1.128,2.271,1.24,3.319H20.553 C20.665,4.952,20.988,3.504,21.792,2.681z M46.544,53.979C46.538,54.288,46.4,57,43.636,57H15.364 c-2.734,0-2.898-2.717-2.909-3.042L10.542,8h37.915L46.544,53.979z"/></g></svg>');
margin: 12px 6px 12px 6px;
background: $red-8;
}

Some files were not shown because too many files have changed in this diff Show More