Compare commits
290 Commits
v1.0.0
...
v1.8.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daae592b5d | ||
|
|
fbc98bce08 | ||
|
|
36204ff81b | ||
|
|
f09a619d41 | ||
|
|
f8e6cc78dc | ||
|
|
3b2ed644a3 | ||
|
|
49a8669970 | ||
|
|
7f97686b8f | ||
|
|
af2626c443 | ||
|
|
20027d0efe | ||
|
|
885b4c6689 | ||
|
|
0d929595cc | ||
|
|
998bc43d8c | ||
|
|
881bd54c4f | ||
|
|
7f64614f60 | ||
|
|
d077241f09 | ||
|
|
b57b6bcc12 | ||
|
|
07cfcc9c17 | ||
|
|
7f323b5abe | ||
|
|
b14de2eb53 | ||
|
|
55a3d1fd89 | ||
|
|
986b05f89a | ||
|
|
b7c214e910 | ||
|
|
ea3419e8b4 | ||
|
|
b29147a95b | ||
|
|
b3f2cf3064 | ||
|
|
bbea9df8b8 | ||
|
|
44dd4fa00c | ||
|
|
2006b02f59 | ||
|
|
c040f95e25 | ||
|
|
90f39499a1 | ||
|
|
6978171885 | ||
|
|
166599a2c8 | ||
|
|
15022ba3a0 | ||
|
|
dc24a40a37 | ||
|
|
80dc91ca05 | ||
|
|
f6d6a1f77f | ||
|
|
d6e7507b2c | ||
|
|
e7a5e01379 | ||
|
|
e0504a0c88 | ||
|
|
6cfa9922de | ||
|
|
72a3f87c28 | ||
|
|
c94e5ca41f | ||
|
|
a05d8eeb07 | ||
|
|
024859ff45 | ||
|
|
042fda0e8c | ||
|
|
885c4dea9f | ||
|
|
0b1e2002d0 | ||
|
|
2f9368a275 | ||
|
|
2a11149034 | ||
|
|
918a691ba3 | ||
|
|
3e1576d494 | ||
|
|
3101af8a5c | ||
|
|
162b11bd7a | ||
|
|
0e5bcb8a79 | ||
|
|
b2b8d1b5d0 | ||
|
|
c2bda4abc6 | ||
|
|
ee7875cc1e | ||
|
|
3ef4a79547 | ||
|
|
a9a1dc6376 | ||
|
|
b682fd14fa | ||
|
|
b278522e35 | ||
|
|
982a574512 | ||
|
|
52f8df5183 | ||
|
|
9538c9036e | ||
|
|
696361df4a | ||
|
|
4a8e90bd43 | ||
|
|
62340eb9c6 | ||
|
|
ff04981cf5 | ||
|
|
f37e26bfc2 | ||
|
|
73234832b6 | ||
|
|
85456a019e | ||
|
|
36fea6e95b | ||
|
|
cc00387136 | ||
|
|
cd8a2bbf99 | ||
|
|
bdda465f33 | ||
|
|
6e22d10b02 | ||
|
|
48bbceabc8 | ||
|
|
cc1dfee017 | ||
|
|
ce19cb8842 | ||
|
|
b816c09802 | ||
|
|
8dbeecf71e | ||
|
|
5faa727ef8 | ||
|
|
5228ff671a | ||
|
|
038b6780d2 | ||
|
|
0a793b90cc | ||
|
|
ab312bfe20 | ||
|
|
504d1bf866 | ||
|
|
a4387b62ec | ||
|
|
1cce90dcf2 | ||
|
|
8820dcd59e | ||
|
|
638f7ba197 | ||
|
|
e1effd2a45 | ||
|
|
a9c48a8394 | ||
|
|
feeda79923 | ||
|
|
9d4ed4ca9f | ||
|
|
0b37b33530 | ||
|
|
b67d2ba58c | ||
|
|
5b6d31ce31 | ||
|
|
409af7f205 | ||
|
|
060520bd7f | ||
|
|
e396e043c6 | ||
|
|
4a189fc698 | ||
|
|
cac1cfa84a | ||
|
|
6317b384d9 | ||
|
|
fa2ccfe42e | ||
|
|
30772ec720 | ||
|
|
eec10491d6 | ||
|
|
e46f9cf9e7 | ||
|
|
1d1cd46c2b | ||
|
|
536ec14b93 | ||
|
|
45c6361805 | ||
|
|
a455ff54bc | ||
|
|
0e54739980 | ||
|
|
7e9b3e5b26 | ||
|
|
86393ad9ab | ||
|
|
672863b48f | ||
|
|
97f17d32ee | ||
|
|
be10baf971 | ||
|
|
9607c15c2b | ||
|
|
65ea597c08 | ||
|
|
850dfc9712 | ||
|
|
3c9ba43ad1 | ||
|
|
b4790397c9 | ||
|
|
9d6955b81e | ||
|
|
5ffdf9988a | ||
|
|
5f1d46c7b2 | ||
|
|
a2c8a73d3e | ||
|
|
4945e53553 | ||
|
|
88d4f8afcf | ||
|
|
15b1640f89 | ||
|
|
216016a4be | ||
|
|
a7cd8066f8 | ||
|
|
295318e6a6 | ||
|
|
d26b6f6e9f | ||
|
|
c8a2ece0d6 | ||
|
|
e9ba79974b | ||
|
|
beb54035cf | ||
|
|
1ccc95fae4 | ||
|
|
fa3fa39696 | ||
|
|
b9bf9e360a | ||
|
|
ecbb505c97 | ||
|
|
789a58bd7a | ||
|
|
c30da607cb | ||
|
|
be197f2b69 | ||
|
|
d4b466b04f | ||
|
|
95093326e0 | ||
|
|
3e5c1c2656 | ||
|
|
c07f3e8b9f | ||
|
|
d367ac8391 | ||
|
|
0609ef0e27 | ||
|
|
adb87d7029 | ||
|
|
23bec48ebb | ||
|
|
685f3a3a58 | ||
|
|
f4489c9921 | ||
|
|
352c93bf88 | ||
|
|
27caa60e0c | ||
|
|
e0f188909f | ||
|
|
0b78e9e70c | ||
|
|
ca797cd165 | ||
|
|
15d729c6ac | ||
|
|
af1d1dcd0c | ||
|
|
b21c630208 | ||
|
|
ef68dadcd7 | ||
|
|
8a7348ed6a | ||
|
|
5df5b5f112 | ||
|
|
c9677385f8 | ||
|
|
220109a157 | ||
|
|
2e2d022c9b | ||
|
|
63c4da0b8d | ||
|
|
e1c94ecf15 | ||
|
|
60a9f2cc15 | ||
|
|
06f0f6f014 | ||
|
|
69aba94590 | ||
|
|
52ce1e2660 | ||
|
|
7fc3910009 | ||
|
|
619231e32f | ||
|
|
a22b49a112 | ||
|
|
b77089388f | ||
|
|
b35155b9e5 | ||
|
|
5bb51bb131 | ||
|
|
8a8e0b53fc | ||
|
|
2b00384219 | ||
|
|
bd695c53fd | ||
|
|
24ddf0657b | ||
|
|
e70546fb56 | ||
|
|
4ceb85ae51 | ||
|
|
41b0c8e5ca | ||
|
|
0acdd67e39 | ||
|
|
caca7b8c41 | ||
|
|
24de2dbcb3 | ||
|
|
9a14801990 | ||
|
|
de98ed81cd | ||
|
|
3f7b65dee9 | ||
|
|
8c09aa0ff6 | ||
|
|
ce47f80e8e | ||
|
|
e434f59f9a | ||
|
|
a4fbf67d73 | ||
|
|
1aea90cb07 | ||
|
|
20b6660fa9 | ||
|
|
815628c5ee | ||
|
|
6caa3e312c | ||
|
|
94829d9b83 | ||
|
|
7be22b091f | ||
|
|
fff5e5c0e1 | ||
|
|
f1ece27c99 | ||
|
|
5e48da6940 | ||
|
|
28fe1aaa89 | ||
|
|
f846935a2a | ||
|
|
42b452b9f8 | ||
|
|
514535a607 | ||
|
|
55f24b2de2 | ||
|
|
24d76c2fb6 | ||
|
|
f2ff2b4940 | ||
|
|
6d1563e22a | ||
|
|
9a3c181442 | ||
|
|
010b7336cd | ||
|
|
00c197e2ee | ||
|
|
c6a98d93e4 | ||
|
|
edd8aae7a7 | ||
|
|
3677d43aab | ||
|
|
0cdba65e48 | ||
|
|
022fc06257 | ||
|
|
61bc73e705 | ||
|
|
e0cf9a89f9 | ||
|
|
bc92df8083 | ||
|
|
71947bbe2c | ||
|
|
5a029e2786 | ||
|
|
7f9a39c330 | ||
|
|
633ccf427c | ||
|
|
51e4608c19 | ||
|
|
612e620ffc | ||
|
|
642076a231 | ||
|
|
f63639782c | ||
|
|
02615088ff | ||
|
|
5aa3bc86eb | ||
|
|
d5769d56c1 | ||
|
|
8eb0bc147c | ||
|
|
96589a2658 | ||
|
|
34e39edcda | ||
|
|
6d00a8e3aa | ||
|
|
c29b3a7a25 | ||
|
|
a99bf15332 | ||
|
|
7074800ecc | ||
|
|
80fb09d941 | ||
|
|
afabc25037 | ||
|
|
c9e7a3f40a | ||
|
|
4d82106aff | ||
|
|
4c0e261a8e | ||
|
|
9e3935b3b2 | ||
|
|
e4f71fe402 | ||
|
|
06c71f4e65 | ||
|
|
9fcf67d667 | ||
|
|
d1318daaca | ||
|
|
87a0c577bb | ||
|
|
bcc81e1ad8 | ||
|
|
1f8f3b3a36 | ||
|
|
610b61831d | ||
|
|
cf0b394b05 | ||
|
|
a36b11f07d | ||
|
|
93c9ce0cad | ||
|
|
af88db42b2 | ||
|
|
0c6ccdc0a1 | ||
|
|
6d3f8171e5 | ||
|
|
3f1c570e84 | ||
|
|
cd88ae264e | ||
|
|
b2abcae319 | ||
|
|
41a5c675bf | ||
|
|
ac9f896a22 | ||
|
|
36d57914b2 | ||
|
|
800ba5dd0d | ||
|
|
a793f7b3b4 | ||
|
|
0d6dfb8319 | ||
|
|
c5d2e17615 | ||
|
|
93595f3877 | ||
|
|
c21329ac4e | ||
|
|
8500a3f7c6 | ||
|
|
4ffdd2cfb6 | ||
|
|
b4f2ba41be | ||
|
|
8ebc0cd965 | ||
|
|
405d10766a | ||
|
|
2a713c22f1 | ||
|
|
d6ccb7338c | ||
|
|
4d799182da | ||
|
|
f54f4d0afd | ||
|
|
988a9fb1a1 | ||
|
|
283a32e2bb | ||
|
|
330131f390 | ||
|
|
299649cea2 | ||
|
|
0a03a2c6fc |
@@ -1,36 +1,23 @@
|
||||
stages:
|
||||
- check-dco
|
||||
- go-fmt
|
||||
- go-test
|
||||
- build-src
|
||||
- build-docker
|
||||
- docker-registry
|
||||
|
||||
check-dco:
|
||||
stage: check-dco
|
||||
image: debian:buster
|
||||
except:
|
||||
- master
|
||||
script:
|
||||
- apt update
|
||||
- apt install -y curl git jq
|
||||
- bash ./scripts/check-dco
|
||||
- docker-registry-master
|
||||
- docker-registry-tags
|
||||
|
||||
build-src:
|
||||
stage: build-src
|
||||
image: debian:buster
|
||||
variables:
|
||||
GOPATH: $CI_PROJECT_DIR
|
||||
except:
|
||||
- master
|
||||
- tags
|
||||
before_script:
|
||||
- bash $CI_PROJECT_DIR/scripts/gitlab-ci-build-prescript
|
||||
script:
|
||||
- apt update
|
||||
- apt install -y curl gnupg git make golang
|
||||
- curl -sL https://deb.nodesource.com/setup_10.x | bash -
|
||||
- apt update
|
||||
- apt install -y nodejs
|
||||
- npm install -g html-minifier@3.5.7 uglify-js@3.4.1 sass@1.5.1
|
||||
- mkdir -p src/gitlab.com/commento && cd src/gitlab.com/commento && ln -s $CI_PROJECT_DIR && cd $CI_PROJECT_NAME
|
||||
- export GOPATH=/go
|
||||
- export PATH=$PATH:/go/bin
|
||||
- cd /go/src/$CI_PROJECT_NAME
|
||||
- make devel
|
||||
- make prod
|
||||
|
||||
@@ -41,12 +28,13 @@ build-docker:
|
||||
- docker:dind
|
||||
except:
|
||||
- master
|
||||
- tags
|
||||
script:
|
||||
- docker build -t commento-ce .
|
||||
- docker build -t commento .
|
||||
|
||||
go-test:
|
||||
stage: go-test
|
||||
image: golang:1.10.2
|
||||
image: golang:1.14
|
||||
services:
|
||||
- postgres:latest
|
||||
variables:
|
||||
@@ -54,32 +42,52 @@ go-test:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: commento_test
|
||||
COMMENTO_POSTGRES: postgres://postgres:postgres@postgres/commento_test?sslmode=disable
|
||||
GOPATH: $CI_PROJECT_DIR
|
||||
except:
|
||||
- master
|
||||
- tags
|
||||
before_script:
|
||||
- mkdir -p /go/src /go/bin /go/pkg
|
||||
- export GOPATH=/go
|
||||
- export PATH=$PATH:/go/bin
|
||||
- ln -s $CI_PROJECT_DIR /go/src/$CI_PROJECT_NAME
|
||||
script:
|
||||
- mkdir -p src/gitlab.com/commento && cd src/gitlab.com/commento && ln -s $CI_PROJECT_DIR && cd $CI_PROJECT_NAME
|
||||
- cd /go/src/$CI_PROJECT_NAME
|
||||
- make test
|
||||
|
||||
go-fmt:
|
||||
stage: go-fmt
|
||||
image: golang:1.10.2
|
||||
image: golang:1.14
|
||||
except:
|
||||
- master
|
||||
- tags
|
||||
script:
|
||||
- cd api
|
||||
- test -z $(go fmt)
|
||||
|
||||
docker-registry:
|
||||
stage: docker-registry
|
||||
docker-registry-master:
|
||||
stage: docker-registry-master
|
||||
image: docker:stable
|
||||
services:
|
||||
- docker:dind
|
||||
only:
|
||||
- master@commento/commento-ce
|
||||
- master@commento/commento
|
||||
before_script:
|
||||
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com
|
||||
script:
|
||||
- docker pull registry.gitlab.com/commento/commento-ce:latest || true
|
||||
- docker build --cache-from registry.gitlab.com/commento/commento-ce:latest --tag registry.gitlab.com/commento/commento-ce:latest .
|
||||
- docker push registry.gitlab.com/commento/commento-ce:latest
|
||||
- docker pull registry.gitlab.com/commento/commento:latest || true
|
||||
- docker build --cache-from registry.gitlab.com/commento/commento:latest --tag registry.gitlab.com/commento/commento:latest .
|
||||
- docker push registry.gitlab.com/commento/commento:latest
|
||||
|
||||
docker-registry-tags:
|
||||
stage: docker-registry-tags
|
||||
image: docker:stable
|
||||
services:
|
||||
- docker:dind
|
||||
only:
|
||||
- tags
|
||||
before_script:
|
||||
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com
|
||||
script:
|
||||
- apk add git
|
||||
- docker build --tag registry.gitlab.com/commento/commento:$(git describe --tags) .
|
||||
- docker push registry.gitlab.com/commento/commento:$(git describe --tags)
|
||||
|
||||
68
Dockerfile
68
Dockerfile
@@ -1,64 +1,48 @@
|
||||
# backend build (api server)
|
||||
FROM golang:1.10.2-alpine AS api-build
|
||||
|
||||
COPY ./api /go/src/commento-ce/api
|
||||
WORKDIR /go/src/commento-ce/api
|
||||
|
||||
RUN apk update && apk add bash make git
|
||||
FROM golang:1.14-alpine AS api-build
|
||||
RUN apk add --no-cache --update bash dep make git curl g++
|
||||
|
||||
COPY ./api /go/src/commento/api/
|
||||
WORKDIR /go/src/commento/api
|
||||
RUN make prod -j$(($(nproc) + 1))
|
||||
|
||||
|
||||
# frontend build (html, js, css, images)
|
||||
FROM node:10.3.0-alpine AS frontend-build
|
||||
|
||||
COPY ./frontend /commento-ce/frontend/
|
||||
WORKDIR /commento-ce/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
|
||||
FROM node:10-alpine AS frontend-build
|
||||
RUN apk add --no-cache --update bash make python2 g++
|
||||
|
||||
COPY ./frontend /commento/frontend
|
||||
WORKDIR /commento/frontend/
|
||||
RUN make prod -j$(($(nproc) + 1))
|
||||
|
||||
|
||||
# templates build
|
||||
FROM alpine:3.7 AS templates-build
|
||||
|
||||
COPY ./templates /commento-ce/templates
|
||||
WORKDIR /commento-ce/templates
|
||||
|
||||
RUN apk update && apk add bash make
|
||||
# templates and db build
|
||||
FROM alpine:3.9 AS templates-db-build
|
||||
RUN apk add --no-cache --update bash make
|
||||
|
||||
COPY ./templates /commento/templates
|
||||
WORKDIR /commento/templates
|
||||
RUN make prod -j$(($(nproc) + 1))
|
||||
|
||||
|
||||
# db build
|
||||
FROM alpine:3.7 AS db-build
|
||||
|
||||
COPY ./db /commento-ce/db
|
||||
WORKDIR /commento-ce/db
|
||||
|
||||
RUN apk update && apk add bash make
|
||||
|
||||
COPY ./db /commento/db
|
||||
WORKDIR /commento/db
|
||||
RUN make prod -j$(($(nproc) + 1))
|
||||
|
||||
|
||||
# final image
|
||||
FROM alpine:3.7
|
||||
RUN apk add --no-cache --update ca-certificates
|
||||
|
||||
COPY --from=api-build /go/src/commento-ce/api/build/prod/commento-ce /commento-ce/commento-ce
|
||||
COPY --from=frontend-build /commento-ce/frontend/build/prod/*.html /commento-ce/
|
||||
COPY --from=frontend-build /commento-ce/frontend/build/prod/css/*.css /commento-ce/css/
|
||||
COPY --from=frontend-build /commento-ce/frontend/build/prod/js/*.js /commento-ce/js/
|
||||
COPY --from=frontend-build /commento-ce/frontend/build/prod/images/* /commento-ce/images/
|
||||
COPY --from=templates-build /commento-ce/templates/build/prod/templates/ /commento-ce/templates/
|
||||
COPY --from=db-build /commento-ce/db/build/prod/db/ /commento-ce/db/
|
||||
|
||||
RUN apk update && apk add ca-certificates --no-cache
|
||||
COPY --from=api-build /go/src/commento/api/build/prod/commento /commento/commento
|
||||
COPY --from=frontend-build /commento/frontend/build/prod/js /commento/js
|
||||
COPY --from=frontend-build /commento/frontend/build/prod/css /commento/css
|
||||
COPY --from=frontend-build /commento/frontend/build/prod/images /commento/images
|
||||
COPY --from=frontend-build /commento/frontend/build/prod/fonts /commento/fonts
|
||||
COPY --from=frontend-build /commento/frontend/build/prod/*.html /commento/
|
||||
COPY --from=templates-db-build /commento/templates/build/prod/templates /commento/templates/
|
||||
COPY --from=templates-db-build /commento/db/build/prod/db /commento/db/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
WORKDIR /commento-ce/
|
||||
|
||||
WORKDIR /commento/
|
||||
ENV COMMENTO_BIND_ADDRESS="0.0.0.0"
|
||||
ENTRYPOINT ["/commento-ce/commento-ce"]
|
||||
ENTRYPOINT ["/commento/commento"]
|
||||
|
||||
158
Gopkg.lock
generated
158
Gopkg.lock
generated
@@ -1,158 +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]]
|
||||
digest = "1:15042ad3498153684d09f393bbaec6b216c8eec6d61f63dff711de7d64ed8861"
|
||||
name = "github.com/golang/protobuf"
|
||||
packages = ["proto"]
|
||||
pruneopts = "UT"
|
||||
revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
|
||||
version = "v1.1.0"
|
||||
|
||||
[[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:bea0314c10bd362ab623af4880d853b5bad3b63d0ab9945c47e461b8d04203ed"
|
||||
name = "golang.org/x/oauth2"
|
||||
packages = [
|
||||
".",
|
||||
"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/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/oauth2",
|
||||
"golang.org/x/oauth2/google",
|
||||
]
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
45
Gopkg.toml
45
Gopkg.toml
@@ -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
|
||||
61
README.md
61
README.md
@@ -1,62 +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) – [Demo](https://demo.commento.io) – [Documentation](https://docs.commento.io) – [Contributing](https://docs.commento.io/contributing/) – [#commento on Freenode](http://webchat.freenode.net/?channels=%23commento)
|
||||
|
||||
Commento is a discussion platform that you can embed on your blog, news articles, and any place where you want your readers to add comments. Commento is fast, lightweight, and privacy-focused; we'll never sell your data, show ads, embed third-party tracking scripts, or inject affiliate links.
|
||||
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.
|
||||
|
||||
#### Features
|
||||
###### How is this different from Disqus, Facebook Comments, and the rest?
|
||||
|
||||
- Privacy-focused
|
||||
- Super lightweight, allowing for fast pageloads
|
||||
- Automatic spam filtering
|
||||
- Review and approve or delete comments through the moderation interface
|
||||
- Modern interface with a clean design
|
||||
- OAuth support (Google login, for example)
|
||||
- Custom CSS theming
|
||||
- Import from existing services (like Disqus)
|
||||
- Completely free and open source (MIT Expat license)
|
||||
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.
|
||||
|
||||
#### Editions
|
||||
###### Why should I care about my readers' privacy?
|
||||
|
||||
There are three editions of Commento.
|
||||
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?
|
||||
|
||||
- **Commento Community Edition (CE)** is open source software that's freely available under the MIT license.
|
||||
- [**Commento Enterprise Edition (EE)**](https://commento.io/pricing#self-hosted) includes extra features geared towards organizations that want to self-host.
|
||||
- [**Commento Hosted**](https://commento.io) is a hosted version of Commento for those who don't want to host and manage servers. This is currently in private beta and you can [add yourself to the waiting list here](https://commento.io).
|
||||
#### Installation
|
||||
|
||||
#### Installation and Configuration
|
||||
|
||||
See our [documentation on how to install Commento](http://docs.commento.io/installation.html) to get started. We offer several ways to install the software, including a Docker image.
|
||||
|
||||
Once you've installed the software, you need to configure it with various environment variables before starting the service. To learn more about this, refer to our documentation on [configuring Commento](https://docs.commento.io/configuration.html).
|
||||
Read the [documentation to get started](https://docs.commento.io/installation/).
|
||||
|
||||
#### Contributing
|
||||
|
||||
Commento is possible only because of its community. If this is your first contribution to Commento, please go through the [development documentation](https://docs.commento.io/contributing.html) 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!
|
||||
|
||||
#### 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).
|
||||
|
||||
14
api/Makefile
14
api/Makefile
@@ -7,9 +7,9 @@ PROD_BUILD_DIR = $(BUILD_DIR)/prod
|
||||
GO_SRC_DIR = .
|
||||
GO_SRC_FILES = $(wildcard $(GO_SRC_DIR)/*.go)
|
||||
GO_DEVEL_BUILD_DIR = $(DEVEL_BUILD_DIR)
|
||||
GO_DEVEL_BUILD_BINARY = $(GO_DEVEL_BUILD_DIR)/commento-ce
|
||||
GO_DEVEL_BUILD_BINARY = $(GO_DEVEL_BUILD_DIR)/commento
|
||||
GO_PROD_BUILD_DIR = $(PROD_BUILD_DIR)
|
||||
GO_PROD_BUILD_BINARY = $(GO_PROD_BUILD_DIR)/commento-ce
|
||||
GO_PROD_BUILD_BINARY = $(GO_PROD_BUILD_DIR)/commento
|
||||
|
||||
devel: devel-go
|
||||
|
||||
@@ -25,15 +25,15 @@ clean:
|
||||
# later down the line).
|
||||
|
||||
devel-go:
|
||||
go get .
|
||||
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:
|
||||
go get .
|
||||
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:
|
||||
go get .
|
||||
GO111MODULE=on go mod vendor
|
||||
go test -v .
|
||||
|
||||
$(shell mkdir -p $(GO_DEVEL_BUILD_DIR) $(GO_PROD_BUILD_DIR))
|
||||
|
||||
31
api/akismet.go
Normal file
31
api/akismet.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/adtac/go-akismet/akismet"
|
||||
"os"
|
||||
)
|
||||
|
||||
func isSpam(domain string, userIp string, userAgent string, name string, email string, url string, markdown string) bool {
|
||||
akismetKey := os.Getenv("AKISMET_KEY")
|
||||
if akismetKey == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
res, err := akismet.Check(&akismet.Comment{
|
||||
Blog: domain,
|
||||
UserIP: userIp,
|
||||
UserAgent: userAgent,
|
||||
CommentType: "comment",
|
||||
CommentAuthor: name,
|
||||
CommentAuthorEmail: email,
|
||||
CommentAuthorURL: url,
|
||||
CommentContent: markdown,
|
||||
}, akismetKey)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("error: cannot validate commenet using Akismet: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
@@ -13,7 +13,8 @@ type comment struct {
|
||||
Html string `json:"html"`
|
||||
ParentHex string `json:"parentHex"`
|
||||
Score int `json:"score"`
|
||||
State string `json:"state"`
|
||||
State string `json:"state,omitempty"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
Direction int `json:"direction"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ func commentApprove(commentHex string) error {
|
||||
|
||||
statement := `
|
||||
UPDATE comments
|
||||
SET state = 'approved'
|
||||
SET state = 'approved'
|
||||
WHERE commentHex = $1;
|
||||
`
|
||||
|
||||
|
||||
66
api/comment_count.go
Normal file
66
api/comment_count.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/lib/pq"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func commentCount(domain string, paths []string) (map[string]int, error) {
|
||||
commentCounts := map[string]int{}
|
||||
|
||||
if domain == "" {
|
||||
return nil, errorMissingField
|
||||
}
|
||||
|
||||
if len(paths) == 0 {
|
||||
return nil, errorEmptyPaths
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT path, commentCount
|
||||
FROM pages
|
||||
WHERE domain = $1 AND path = ANY($2);
|
||||
`
|
||||
rows, err := db.Query(statement, domain, pq.Array(paths))
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get comments: %v", err)
|
||||
return nil, errorInternal
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var path string
|
||||
var commentCount int
|
||||
if err = rows.Scan(&path, &commentCount); err != nil {
|
||||
logger.Errorf("cannot scan path and commentCount: %v", err)
|
||||
return nil, errorInternal
|
||||
}
|
||||
|
||||
commentCounts[path] = commentCount
|
||||
}
|
||||
|
||||
return commentCounts, nil
|
||||
}
|
||||
|
||||
func commentCountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
Domain *string `json:"domain"`
|
||||
Paths *[]string `json:"paths"`
|
||||
}
|
||||
|
||||
var x request
|
||||
if err := bodyUnmarshal(r, &x); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
domain := domainStrip(*x.Domain)
|
||||
|
||||
commentCounts, err := commentCount(domain, *x.Paths)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commentCounts": commentCounts})
|
||||
}
|
||||
54
api/comment_count_test.go
Normal file
54
api/comment_count_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCommentCountBasics(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "http://example.com/photo.jpg", "google", "")
|
||||
|
||||
commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||
commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "approved", time.Now().UTC())
|
||||
commentNew(commenterHex, "example.com", "/path.html", "root", "**baz**", "unapproved", time.Now().UTC())
|
||||
|
||||
counts, err := commentCount("example.com", []string{"/path.html"})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error counting comments: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if counts["/path.html"] != 3 {
|
||||
t.Errorf("expected count=3 got count=%d", counts["/path.html"])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentCountNewPage(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
counts, err := commentCount("example.com", []string{"/path.html"})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error counting comments: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if counts["/path.html"] != 0 {
|
||||
t.Errorf("expected count=0 got count=%d", counts["/path.html"])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentCountEmpty(t *testing.T) {
|
||||
if _, err := commentCount("example.com", []string{""}); err != nil {
|
||||
t.Errorf("unexpected error counting comments on empty path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := commentCount("", []string{""}); err == nil {
|
||||
t.Errorf("expected error not found counting comments with empty everything")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ func commentDelete(commentHex string) error {
|
||||
}
|
||||
|
||||
statement := `
|
||||
DELETE FROM comments
|
||||
WHERE commentHex=$1;
|
||||
UPDATE comments
|
||||
SET deleted = true, markdown = '[deleted]', html = '[deleted]', commenterHex = 'anonymous'
|
||||
WHERE commentHex = $1;
|
||||
`
|
||||
_, err := db.Exec(statement, commentHex)
|
||||
|
||||
@@ -41,6 +42,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,7 +60,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -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
66
api/comment_edit.go
Normal 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
50
api/comment_get.go
Normal 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
|
||||
}
|
||||
@@ -6,27 +6,33 @@ import (
|
||||
)
|
||||
|
||||
func commentList(commenterHex string, domain string, path string, includeUnapproved bool) ([]comment, map[string]commenter, error) {
|
||||
if commenterHex == "" || domain == "" || path == "" {
|
||||
// path can be empty
|
||||
if commenterHex == "" || domain == "" {
|
||||
return nil, nil, errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT commentHex, commenterHex, markdown, html, parentHex, score, state, creationDate
|
||||
SELECT
|
||||
commentHex,
|
||||
commenterHex,
|
||||
markdown,
|
||||
html,
|
||||
parentHex,
|
||||
score,
|
||||
state,
|
||||
deleted,
|
||||
creationDate
|
||||
FROM comments
|
||||
WHERE
|
||||
comments.domain = $1 AND
|
||||
comments.path = $2
|
||||
`
|
||||
comments.domain = $1 AND
|
||||
comments.path = $2
|
||||
`
|
||||
|
||||
if !includeUnapproved {
|
||||
if commenterHex == "anonymous" {
|
||||
statement += `
|
||||
AND state = 'approved'
|
||||
`
|
||||
statement += `AND state = 'approved'`
|
||||
} else {
|
||||
statement += `
|
||||
AND (state = 'approved' OR commenterHex = $3)
|
||||
`
|
||||
statement += `AND (state = 'approved' OR commenterHex = $3)`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,16 +59,25 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
||||
comments := []comment{}
|
||||
for rows.Next() {
|
||||
c := comment{}
|
||||
if err = rows.Scan(&c.CommentHex, &c.CommenterHex, &c.Markdown, &c.Html, &c.ParentHex, &c.Score, &c.State, &c.CreationDate); err != nil {
|
||||
if err = rows.Scan(
|
||||
&c.CommentHex,
|
||||
&c.CommenterHex,
|
||||
&c.Markdown,
|
||||
&c.Html,
|
||||
&c.ParentHex,
|
||||
&c.Score,
|
||||
&c.State,
|
||||
&c.Deleted,
|
||||
&c.CreationDate); err != nil {
|
||||
return nil, nil, errorInternal
|
||||
}
|
||||
|
||||
if commenterHex != "anonymous" {
|
||||
statement = `
|
||||
SELECT direction
|
||||
FROM votes
|
||||
WHERE commentHex=$1 AND commenterHex=$2;
|
||||
`
|
||||
SELECT direction
|
||||
FROM votes
|
||||
WHERE commentHex=$1 AND commenterHex=$2;
|
||||
`
|
||||
row := db.QueryRow(statement, c.CommentHex, commenterHex)
|
||||
|
||||
if err = row.Scan(&c.Direction); err != nil {
|
||||
@@ -71,6 +86,10 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
||||
}
|
||||
}
|
||||
|
||||
if commenterHex != c.CommenterHex {
|
||||
c.Markdown = ""
|
||||
}
|
||||
|
||||
if !includeUnapproved {
|
||||
c.State = ""
|
||||
}
|
||||
@@ -111,8 +130,16 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
p, err := pageGet(domain, path)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
commenterHex := "anonymous"
|
||||
isModerator := false
|
||||
modList := map[string]bool{}
|
||||
|
||||
if *x.CommenterToken != "anonymous" {
|
||||
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
||||
if err != nil {
|
||||
@@ -127,11 +154,15 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
for _, mod := range d.Moderators {
|
||||
modList[mod.Email] = true
|
||||
if mod.Email == c.Email {
|
||||
isModerator = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, mod := range d.Moderators {
|
||||
modList[mod.Email] = true
|
||||
}
|
||||
}
|
||||
|
||||
domainViewRecord(domain, commenterHex)
|
||||
@@ -142,15 +173,33 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_commenters := map[string]commenter{}
|
||||
for commenterHex, cr := range commenters {
|
||||
if _, ok := modList[cr.Email]; ok {
|
||||
cr.IsModerator = true
|
||||
}
|
||||
cr.Email = ""
|
||||
_commenters[commenterHex] = cr
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{
|
||||
"success": true,
|
||||
"domain": domain,
|
||||
"comments": comments,
|
||||
"commenters": commenters,
|
||||
"commenters": _commenters,
|
||||
"requireModeration": d.RequireModeration,
|
||||
"requireIdentification": d.RequireIdentification,
|
||||
"isFrozen": d.State == "frozen",
|
||||
"isModerator": isModerator,
|
||||
"configuredOauths": configuredOauths,
|
||||
"defaultSortPolicy": d.DefaultSortPolicy,
|
||||
"attributes": p,
|
||||
"configuredOauths": map[string]bool{
|
||||
"commento": d.CommentoProvider,
|
||||
"google": googleConfigured && d.GoogleProvider,
|
||||
"twitter": twitterConfigured && d.TwitterProvider,
|
||||
"github": githubConfigured && d.GithubProvider,
|
||||
"gitlab": gitlabConfigured && d.GitlabProvider,
|
||||
"sso": d.SsoProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin
|
||||
return "", errorMissingField
|
||||
}
|
||||
|
||||
p, err := pageGet(domain, path)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get page attributes: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
if p.IsLocked {
|
||||
return "", errorThreadLocked
|
||||
}
|
||||
|
||||
commentHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -20,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)
|
||||
@@ -63,20 +77,34 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if d.RequireIdentification && *x.CommenterToken == "anonymous" {
|
||||
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// logic: (empty column indicates the value doesn't matter)
|
||||
// | anonymous | moderator | requireIdentification | requireModeration | approved? |
|
||||
// |-----------+-----------+-----------------------+-------------------+-----------|
|
||||
// | yes | | | | no |
|
||||
// | no | yes | | | yes |
|
||||
// | no | no | | yes | yes |
|
||||
// | no | no | | no | no |
|
||||
// | 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
|
||||
|
||||
if *x.CommenterToken == "anonymous" {
|
||||
state = "unapproved"
|
||||
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"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
||||
if err != nil {
|
||||
@@ -98,10 +126,14 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if isModerator {
|
||||
state = "approved"
|
||||
} else {
|
||||
if d.RequireModeration {
|
||||
state = "unapproved"
|
||||
if isSpam(*x.Domain, getIp(r), getUserAgent(r), c.Name, c.Email, c.Link, *x.Markdown) {
|
||||
state = "flagged"
|
||||
} else {
|
||||
state = "approved"
|
||||
if d.RequireModeration {
|
||||
state = "unapproved"
|
||||
} else {
|
||||
state = "approved"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,5 +144,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "approved": state == "approved"})
|
||||
// TODO: reuse html in commentNew and do only one markdown to HTML conversion?
|
||||
html := markdownToHtml(*x.Markdown)
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": html})
|
||||
if smtpConfigured {
|
||||
go emailNotificationNew(d, path, commenterHex, commentHex, html, *x.ParentHex, state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -56,3 +56,18 @@ func TestCommentNewUpvoted(t *testing.T) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentNewThreadLocked(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
pageNew("example.com", "/path.html")
|
||||
p, _ := pageGet("example.com", "/path.html")
|
||||
p.IsLocked = true
|
||||
pageUpdate(p)
|
||||
|
||||
_, err := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||
if err == nil {
|
||||
t.Errorf("expected error not found creating a new comment on a locked thread")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,4 +12,5 @@ type commenter struct {
|
||||
Photo string `json:"photo"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
JoinDate time.Time `json:"joinDate,omitempty"`
|
||||
IsModerator bool `json:"isModerator"`
|
||||
}
|
||||
|
||||
@@ -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,21 +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
|
||||
}
|
||||
|
||||
return commenterGetByHex(commenterHex)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -67,5 +67,18 @@ func commenterLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken})
|
||||
// TODO: modify commenterLogin to directly return c?
|
||||
c, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ func commenterNew(email string, name string, link string, photo string, provider
|
||||
return "", errorEmailAlreadyExists
|
||||
}
|
||||
|
||||
if err := emailNew(email); err != nil {
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
commenterHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", errorInternal
|
||||
|
||||
61
api/commenter_photo.go
Normal file
61
api/commenter_photo.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
)
|
||||
|
||||
func commenterPhotoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := commenterGetByHex(r.FormValue("commenterHex"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
url := c.Photo
|
||||
if c.Provider == "google" {
|
||||
if strings.HasSuffix(url, "photo.jpg") {
|
||||
url += "?sz=38"
|
||||
} else {
|
||||
url += "=s38"
|
||||
}
|
||||
} else if c.Provider == "github" {
|
||||
url += "&s=38"
|
||||
} else if c.Provider == "twitter" {
|
||||
url += "?size=normal"
|
||||
} else if c.Provider == "gitlab" {
|
||||
url += "?width=38"
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,11 @@ func commenterSelfHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commenter": c})
|
||||
e, err := emailGet(c.Email)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commenter": c, "email": e})
|
||||
}
|
||||
|
||||
@@ -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
69
api/commenter_update.go
Normal 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})
|
||||
}
|
||||
@@ -43,8 +43,26 @@ func configParse() error {
|
||||
"SMTP_PORT": "",
|
||||
"SMTP_FROM_ADDRESS": "",
|
||||
|
||||
"AKISMET_KEY": "",
|
||||
|
||||
"GOOGLE_KEY": "",
|
||||
"GOOGLE_SECRET": "",
|
||||
|
||||
"GITHUB_KEY": "",
|
||||
"GITHUB_SECRET": "",
|
||||
|
||||
"TWITTER_KEY": "",
|
||||
"TWITTER_SECRET": "",
|
||||
|
||||
"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 {
|
||||
@@ -55,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) == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
package main
|
||||
|
||||
var edition = "ce"
|
||||
var version = "v1.0.0"
|
||||
var version string
|
||||
|
||||
25
api/cron_domain_export_cleanup.go
Normal file
25
api/cron_domain_export_cleanup.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func domainExportCleanupBegin() error {
|
||||
go func() {
|
||||
for {
|
||||
statement := `
|
||||
DELETE FROM exports
|
||||
WHERE creationDate < $1;
|
||||
`
|
||||
_, err := db.Exec(statement, time.Now().UTC().AddDate(0, 0, -7))
|
||||
if err != nil {
|
||||
logger.Errorf("error cleaning up export rows: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Hour)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
25
api/cron_sso_token.go
Normal file
25
api/cron_sso_token.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func ssoTokenCleanupBegin() error {
|
||||
go func() {
|
||||
for {
|
||||
statement := `
|
||||
DELETE FROM ssoTokens
|
||||
WHERE creationDate < $1;
|
||||
`
|
||||
_, err := db.Exec(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute))
|
||||
if err != nil {
|
||||
logger.Errorf("error cleaning up export rows: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Minute)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
25
api/cron_views_cleanup.go
Normal file
25
api/cron_views_cleanup.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func viewsCleanupBegin() error {
|
||||
go func() {
|
||||
for {
|
||||
statement := `
|
||||
DELETE FROM views
|
||||
WHERE viewDate < $1;
|
||||
`
|
||||
_, err := db.Exec(statement, time.Now().UTC().AddDate(0, 0, -45))
|
||||
if err != nil {
|
||||
logger.Errorf("error cleaning up views: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(24 * time.Hour)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var goMigrations = map[string](func() error){
|
||||
"20190213033530-email-notifications.sql": migrateEmails,
|
||||
}
|
||||
|
||||
func migrate() error {
|
||||
return migrateFromDir(os.Getenv("STATIC") + "/db")
|
||||
}
|
||||
@@ -18,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)
|
||||
@@ -59,16 +63,23 @@ 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)
|
||||
return err
|
||||
}
|
||||
|
||||
if fn, ok := goMigrations[file.Name()]; ok {
|
||||
if err = fn(); err != nil {
|
||||
logger.Errorf("cannot execute Go migration associated with SQL %s: %v", f, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
||||
37
api/database_migrate_email_notifications.go
Normal file
37
api/database_migrate_email_notifications.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func migrateEmails() error {
|
||||
statement := `
|
||||
SELECT commenters.email
|
||||
FROM commenters
|
||||
UNION
|
||||
SELECT owners.email
|
||||
FROM owners
|
||||
UNION
|
||||
SELECT moderators.email
|
||||
FROM moderators;
|
||||
`
|
||||
rows, err := db.Query(statement)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get comments: %v", err)
|
||||
return errorDatabaseMigration
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err = rows.Scan(&email); err != nil {
|
||||
logger.Errorf("cannot get email from tables during migration: %v", err)
|
||||
return errorDatabaseMigration
|
||||
}
|
||||
|
||||
if err = emailNew(email); err != nil {
|
||||
logger.Errorf("cannot insert email during migration: %v", err)
|
||||
return errorDatabaseMigration
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,14 +5,25 @@ import (
|
||||
)
|
||||
|
||||
type domain struct {
|
||||
Domain string `json:"domain"`
|
||||
OwnerHex string `json:"ownerHex"`
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
State string `json:"state"`
|
||||
ImportedComments bool `json:"importedComments"`
|
||||
AutoSpamFilter bool `json:"autoSpamFilter"`
|
||||
RequireModeration bool `json:"requireModeration"`
|
||||
RequireIdentification bool `json:"requireIdentification"`
|
||||
Moderators []moderator `json:"moderators"`
|
||||
Domain string `json:"domain"`
|
||||
OwnerHex string `json:"ownerHex"`
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
State string `json:"state"`
|
||||
ImportedComments bool `json:"importedComments"`
|
||||
AutoSpamFilter bool `json:"autoSpamFilter"`
|
||||
RequireModeration bool `json:"requireModeration"`
|
||||
RequireIdentification bool `json:"requireIdentification"`
|
||||
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
|
||||
Moderators []moderator `json:"moderators"`
|
||||
EmailNotificationPolicy string `json:"emailNotificationPolicy"`
|
||||
CommentoProvider bool `json:"commentoProvider"`
|
||||
GoogleProvider bool `json:"googleProvider"`
|
||||
TwitterProvider bool `json:"twitterProvider"`
|
||||
GithubProvider bool `json:"githubProvider"`
|
||||
GitlabProvider bool `json:"gitlabProvider"`
|
||||
SsoProvider bool `json:"ssoProvider"`
|
||||
SsoSecret string `json:"ssoSecret"`
|
||||
SsoUrl string `json:"ssoUrl"`
|
||||
DefaultSortPolicy string `json:"defaultSortPolicy"`
|
||||
}
|
||||
|
||||
82
api/domain_clear.go
Normal file
82
api/domain_clear.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func domainClear(domain string) error {
|
||||
if domain == "" {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
DELETE FROM votes
|
||||
USING comments
|
||||
WHERE comments.commentHex = votes.commentHex AND comments.domain = $1;
|
||||
`
|
||||
_, err := db.Exec(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot delete votes: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
statement = `
|
||||
DELETE FROM comments
|
||||
WHERE comments.domain = $1;
|
||||
`
|
||||
_, err = db.Exec(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf(statement, domain)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
statement = `
|
||||
DELETE FROM pages
|
||||
WHERE pages.domain = $1;
|
||||
`
|
||||
_, err = db.Exec(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf(statement, domain)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func domainClearHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
OwnerToken *string `json:"ownerToken"`
|
||||
Domain *string `json:"domain"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if err = domainClear(*x.Domain); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true})
|
||||
}
|
||||
@@ -10,8 +10,7 @@ func domainDelete(domain string) error {
|
||||
}
|
||||
|
||||
statement := `
|
||||
DELETE FROM
|
||||
domains
|
||||
DELETE FROM domains
|
||||
WHERE domain = $1;
|
||||
`
|
||||
_, err := db.Exec(statement, domain)
|
||||
@@ -19,24 +18,13 @@ func domainDelete(domain string) error {
|
||||
return errorNoSuchDomain
|
||||
}
|
||||
|
||||
statement = `
|
||||
DELETE FROM votes
|
||||
USING comments
|
||||
WHERE comments.commentHex = votes.commentHex AND comments.domain = $1;
|
||||
`
|
||||
_, err = db.Exec(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot delete votes: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
statement = `
|
||||
DELETE FROM views
|
||||
WHERE views.domain = $1;
|
||||
`
|
||||
_, err = db.Exec(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot delete views: %v", err)
|
||||
logger.Errorf("cannot delete domain from views: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
@@ -46,17 +34,23 @@ 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 comments
|
||||
WHERE comments.domain = $1;
|
||||
DELETE FROM ssotokens
|
||||
WHERE ssotokens.domain = $1;
|
||||
`
|
||||
_, err = db.Exec(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf(statement, domain)
|
||||
logger.Errorf("cannot delete domain from ssotokens: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
// comments, votes, and pages are handled by domainClear
|
||||
if err = domainClear(domain); err != nil {
|
||||
logger.Errorf("cannot clear domain: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
|
||||
145
api/domain_export.go
Normal file
145
api/domain_export.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func domainExportBeginError(email string, toName string, domain string, err error) {
|
||||
// we're not using err at the moment because it's all errorInternal
|
||||
if err2 := smtpDomainExportError(email, toName, domain); err2 != nil {
|
||||
logger.Errorf("cannot send domain export error email for %s: %v", domain, err2)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func domainExportBegin(email string, toName string, domain string) {
|
||||
e := commentoExportV1{Version: 1, Comments: []comment{}, Commenters: []commenter{}}
|
||||
|
||||
statement := `
|
||||
SELECT commentHex, domain, path, commenterHex, markdown, parentHex, score, state, creationDate
|
||||
FROM comments
|
||||
WHERE domain = $1;
|
||||
`
|
||||
rows1, err := db.Query(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot select comments while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
defer rows1.Close()
|
||||
|
||||
for rows1.Next() {
|
||||
c := comment{}
|
||||
if err = rows1.Scan(&c.CommentHex, &c.Domain, &c.Path, &c.CommenterHex, &c.Markdown, &c.ParentHex, &c.Score, &c.State, &c.CreationDate); err != nil {
|
||||
logger.Errorf("cannot scan comment while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
|
||||
e.Comments = append(e.Comments, c)
|
||||
}
|
||||
|
||||
statement = `
|
||||
SELECT commenters.commenterHex, commenters.email, commenters.name, commenters.link, commenters.photo, commenters.provider, commenters.joinDate
|
||||
FROM commenters, comments
|
||||
WHERE comments.domain = $1 AND commenters.commenterHex = comments.commenterHex;
|
||||
`
|
||||
rows2, err := db.Query(statement, domain)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot select commenters while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
defer rows2.Close()
|
||||
|
||||
for rows2.Next() {
|
||||
c := commenter{}
|
||||
if err := rows2.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil {
|
||||
logger.Errorf("cannot scan commenter while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
|
||||
e.Commenters = append(e.Commenters, c)
|
||||
}
|
||||
|
||||
je, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot marshall JSON while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
|
||||
gje, err := gzipStatic(je)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot gzip JSON while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
|
||||
exportHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot generate exportHex while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
|
||||
statement = `
|
||||
INSERT INTO
|
||||
exports (exportHex, binData, domain, creationDate)
|
||||
VALUES ($1, $2, $3 , $4 );
|
||||
`
|
||||
_, err = db.Exec(statement, exportHex, gje, domain, time.Now().UTC())
|
||||
if err != nil {
|
||||
logger.Errorf("error inserting expiry binary data while exporting %s: %v", domain, err)
|
||||
domainExportBeginError(email, toName, domain, errorInternal)
|
||||
return
|
||||
}
|
||||
|
||||
err = smtpDomainExport(email, toName, domain, exportHex)
|
||||
if err != nil {
|
||||
logger.Errorf("error sending data export email for %s: %v", domain, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func domainExportBeginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
OwnerToken *string `json:"ownerToken"`
|
||||
Domain *string `json:"domain"`
|
||||
}
|
||||
|
||||
var x request
|
||||
if err := bodyUnmarshal(r, &x); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !smtpConfigured {
|
||||
bodyMarshal(w, response{"success": false, "message": errorSmtpNotConfigured.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
o, err := ownerGetByOwnerToken(*x.OwnerToken)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isOwner, err := domainOwnershipVerify(o.OwnerHex, *x.Domain)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !isOwner {
|
||||
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
go domainExportBegin(o.Email, o.Name, *x.Domain)
|
||||
|
||||
bodyMarshal(w, response{"success": true})
|
||||
}
|
||||
32
api/domain_export_download.go
Normal file
32
api/domain_export_download.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func domainExportDownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
exportHex := r.FormValue("exportHex")
|
||||
if exportHex == "" {
|
||||
fmt.Fprintf(w, "Error: empty exportHex\n")
|
||||
return
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT domain, binData, creationDate
|
||||
FROM exports
|
||||
WHERE exportHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, exportHex)
|
||||
|
||||
var domain string
|
||||
var binData []byte
|
||||
var creationDate time.Time
|
||||
if err := row.Scan(&domain, &binData, &creationDate); err != nil {
|
||||
fmt.Fprintf(w, "Error: that exportHex does not exist\n")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s-%v.json.gz"`, domain, creationDate.Unix()))
|
||||
w.Write(binData)
|
||||
}
|
||||
@@ -2,13 +2,61 @@ package main
|
||||
|
||||
import ()
|
||||
|
||||
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
|
||||
`
|
||||
|
||||
func domainsRowScan(s sqlScanner, d *domain) error {
|
||||
return s.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,
|
||||
&d.DefaultSortPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
func domainGet(dmn string) (domain, error) {
|
||||
if dmn == "" {
|
||||
return domain{}, errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification
|
||||
SELECT ` + domainsRowColumns + `
|
||||
FROM domains
|
||||
WHERE domain = $1;
|
||||
`
|
||||
@@ -16,7 +64,7 @@ func domainGet(dmn string) (domain, error) {
|
||||
|
||||
var err error
|
||||
d := domain{}
|
||||
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification); err != nil {
|
||||
if err = domainsRowScan(row, &d); err != nil {
|
||||
return d, errorNoSuchDomain
|
||||
}
|
||||
|
||||
|
||||
168
api/domain_import_commento.go
Normal file
168
api/domain_import_commento.go
Normal 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})
|
||||
}
|
||||
121
api/domain_import_commento_test.go
Normal file
121
api/domain_import_commento_test.go
Normal 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
|
||||
}
|
||||
@@ -18,9 +18,9 @@ type disqusThread struct {
|
||||
|
||||
type disqusAuthor struct {
|
||||
XMLName xml.Name `xml:"author"`
|
||||
IsAnonymous bool `xml:"isAnonymous"`
|
||||
Name string `xml:"name"`
|
||||
Email string `xml:"email"`
|
||||
IsAnonymous bool `xml:"isAnonymous"`
|
||||
Username string `xml:"username"`
|
||||
}
|
||||
|
||||
type disqusThreadId struct {
|
||||
@@ -43,7 +43,6 @@ type disqusPost struct {
|
||||
Id string `xml:"http://disqus.com/disqus-internals id,attr"`
|
||||
ThreadId disqusThreadId `xml:"thread"`
|
||||
ParentId disqusParentId `xml:"parent"`
|
||||
PostId disqusPostId `xml:"post"`
|
||||
Message string `xml:"message"`
|
||||
CreationDate time.Time `xml:"createdAt"`
|
||||
IsDeleted bool `xml:"isDeleted"`
|
||||
@@ -98,24 +97,26 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
||||
|
||||
// Map Disqus emails to commenterHex (if not available, create a new one
|
||||
// with a random password that can be reset later).
|
||||
commenterHex := make(map[string]string)
|
||||
commenterHex := map[string]string{}
|
||||
for _, post := range x.Posts {
|
||||
if post.IsDeleted || post.IsSpam {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := commenterHex[post.Author.Email]; ok {
|
||||
email := post.Author.Username + "@disqus.com"
|
||||
|
||||
if _, ok := commenterHex[email]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
c, err := commenterGetByEmail("commento", post.Author.Email)
|
||||
c, err := commenterGetByEmail("commento", email)
|
||||
if err != nil && err != errorNoSuchCommenter {
|
||||
logger.Errorf("cannot get commenter by email: %v", err)
|
||||
return 0, errorInternal
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
commenterHex[post.Author.Email] = c.CommenterHex
|
||||
commenterHex[email] = c.CommenterHex
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -125,7 +126,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
||||
return 0, errorInternal
|
||||
}
|
||||
|
||||
commenterHex[post.Author.Email], err = commenterNew(post.Author.Email, post.Author.Name, "undefined", "undefined", "commento", randomPassword)
|
||||
commenterHex[email], err = commenterNew(email, post.Author.Name, "undefined", "undefined", "commento", randomPassword)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -134,12 +135,17 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
||||
// For each Disqus post, create a Commento comment. Attempt to convert the
|
||||
// HTML to markdown.
|
||||
numImported := 0
|
||||
disqusIdMap := make(map[string]string)
|
||||
disqusIdMap := map[string]string{}
|
||||
for _, post := range x.Posts {
|
||||
if post.IsDeleted || post.IsSpam {
|
||||
continue
|
||||
}
|
||||
|
||||
cHex := "anonymous"
|
||||
if !post.Author.IsAnonymous {
|
||||
cHex = commenterHex[post.Author.Username+"@disqus.com"]
|
||||
}
|
||||
|
||||
parentHex := "root"
|
||||
if val, ok := disqusIdMap[post.ParentId.Id]; ok {
|
||||
parentHex = val
|
||||
@@ -148,7 +154,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
||||
// TODO: restrict the list of tags to just the basics: <a>, <b>, <i>, <code>
|
||||
// Especially remove <img> (convert it to <a>).
|
||||
commentHex, err := commentNew(
|
||||
commenterHex[post.Author.Email],
|
||||
cHex,
|
||||
domain,
|
||||
pathStrip(threads[post.ThreadId.Id].URL),
|
||||
parentHex,
|
||||
@@ -159,7 +165,7 @@ func domainImportDisqus(domain string, url string) (int, error) {
|
||||
return numImported, err
|
||||
}
|
||||
|
||||
disqusIdMap[post.PostId.Id] = commentHex
|
||||
disqusIdMap[post.Id] = commentHex
|
||||
numImported += 1
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ func domainList(ownerHex string) ([]domain, error) {
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification
|
||||
SELECT ` + domainsRowColumns + `
|
||||
FROM domains
|
||||
WHERE ownerHex=$1;
|
||||
`
|
||||
@@ -23,8 +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); err != nil {
|
||||
var d domain
|
||||
if err = domainsRowScan(rows, &d); err != nil {
|
||||
logger.Errorf("cannot Scan domain: %v", err)
|
||||
return nil, errorInternal
|
||||
}
|
||||
@@ -63,5 +63,14 @@ func domainListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "domains": domains})
|
||||
bodyMarshal(w, response{
|
||||
"success": true,
|
||||
"domains": domains,
|
||||
"configuredOauths": map[string]bool{
|
||||
"google": googleConfigured,
|
||||
"twitter": twitterConfigured,
|
||||
"github": githubConfigured,
|
||||
"gitlab": gitlabConfigured,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ func domainModeratorNew(domain string, email string) error {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
if err := emailNew(email); err != nil {
|
||||
logger.Errorf("cannot create email when creating moderator: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
statement := `
|
||||
INSERT INTO
|
||||
moderators (domain, email, addDate)
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -10,6 +11,10 @@ func domainNew(ownerHex string, name string, domain string) error {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
if strings.Contains(domain, "/") {
|
||||
return errorInvalidDomain
|
||||
}
|
||||
|
||||
statement := `
|
||||
INSERT INTO
|
||||
domains (ownerHex, name, domain, creationDate)
|
||||
|
||||
69
api/domain_sso.go
Normal file
69
api/domain_sso.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func domainSsoSecretNew(domain string) (string, error) {
|
||||
if domain == "" {
|
||||
return "", errorMissingField
|
||||
}
|
||||
|
||||
ssoSecret, err := randomHex(32)
|
||||
if err != nil {
|
||||
logger.Errorf("error generating SSO secret hex: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
statement := `
|
||||
UPDATE domains
|
||||
SET ssoSecret = $2
|
||||
WHERE domain = $1;
|
||||
`
|
||||
_, err = db.Exec(statement, domain, ssoSecret)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update ssoSecret: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
return ssoSecret, nil
|
||||
}
|
||||
|
||||
func domainSsoSecretNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
OwnerToken *string `json:"ownerToken"`
|
||||
Domain *string `json:"domain"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ssoSecret, err := domainSsoSecretNew(domain)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "ssoSecret": ssoSecret})
|
||||
}
|
||||
@@ -5,13 +5,48 @@ import (
|
||||
)
|
||||
|
||||
func domainUpdate(d domain) error {
|
||||
if d.SsoProvider && d.SsoUrl == "" {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
UPDATE domains
|
||||
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6
|
||||
SET
|
||||
name=$2,
|
||||
state=$3,
|
||||
autoSpamFilter=$4,
|
||||
requireModeration=$5,
|
||||
requireIdentification=$6,
|
||||
moderateAllAnonymous=$7,
|
||||
emailNotificationPolicy=$8,
|
||||
commentoProvider=$9,
|
||||
googleProvider=$10,
|
||||
twitterProvider=$11,
|
||||
githubProvider=$12,
|
||||
gitlabProvider=$13,
|
||||
ssoProvider=$14,
|
||||
ssoUrl=$15,
|
||||
defaultSortPolicy=$16
|
||||
WHERE domain=$1;
|
||||
`
|
||||
|
||||
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification)
|
||||
_, err := db.Exec(statement,
|
||||
d.Domain,
|
||||
d.Name,
|
||||
d.State,
|
||||
d.AutoSpamFilter,
|
||||
d.RequireModeration,
|
||||
d.RequireIdentification,
|
||||
d.ModerateAllAnonymous,
|
||||
d.EmailNotificationPolicy,
|
||||
d.CommentoProvider,
|
||||
d.GoogleProvider,
|
||||
d.TwitterProvider,
|
||||
d.GithubProvider,
|
||||
d.GitlabProvider,
|
||||
d.SsoProvider,
|
||||
d.SsoUrl,
|
||||
d.DefaultSortPolicy)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update non-moderators: %v", err)
|
||||
return errorInternal
|
||||
|
||||
13
api/email.go
Normal file
13
api/email.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type email struct {
|
||||
Email string `json:"email"`
|
||||
UnsubscribeSecretHex string `json:"unsubscribeSecretHex"`
|
||||
LastEmailNotificationDate time.Time `json:"lastEmailNotificationDate"`
|
||||
SendReplyNotifications bool `json:"sendReplyNotifications"`
|
||||
SendModeratorNotifications bool `json:"sendModeratorNotifications"`
|
||||
}
|
||||
77
api/email_get.go
Normal file
77
api/email_get.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
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 ` + emailsRowColumns + `
|
||||
FROM emails
|
||||
WHERE email = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, em)
|
||||
|
||||
var e email
|
||||
if err := emailsRowScan(row, &e); err != nil {
|
||||
// TODO: is this the only error?
|
||||
return e, errorNoSuchEmail
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func emailGetByUnsubscribeSecretHex(unsubscribeSecretHex string) (email, error) {
|
||||
statement := `
|
||||
SELECT ` + emailsRowColumns + `
|
||||
FROM emails
|
||||
WHERE unsubscribeSecretHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, unsubscribeSecretHex)
|
||||
|
||||
e := email{}
|
||||
if err := emailsRowScan(row, &e); err != nil {
|
||||
// TODO: is this the only error?
|
||||
return e, errorNoSuchUnsubscribeSecretHex
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func emailGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
UnsubscribeSecretHex *string `json:"unsubscribeSecretHex"`
|
||||
}
|
||||
|
||||
var x request
|
||||
if err := bodyUnmarshal(r, &x); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
e, err := emailGetByUnsubscribeSecretHex(*x.UnsubscribeSecretHex)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "email": e})
|
||||
}
|
||||
66
api/email_moderate.go
Normal file
66
api/email_moderate.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT domain
|
||||
FROM comments
|
||||
WHERE commentHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, commentHex)
|
||||
|
||||
var domain string
|
||||
if err = row.Scan(&domain); err != nil {
|
||||
// TODO: is this the only error?
|
||||
fmt.Fprintf(w, "error: no such comment found (perhaps it has been deleted?)")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if !isModerator {
|
||||
fmt.Fprintf(w, "error: you're not a moderator for that domain")
|
||||
return
|
||||
}
|
||||
|
||||
if action == "approve" {
|
||||
err = commentApprove(commentHex)
|
||||
} else {
|
||||
err = commentDelete(commentHex)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "comment successfully %sd", action)
|
||||
}
|
||||
26
api/email_new.go
Normal file
26
api/email_new.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func emailNew(email string) error {
|
||||
unsubscribeSecretHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
statement := `
|
||||
INSERT INTO
|
||||
emails (email, unsubscribeSecretHex, lastEmailNotificationDate)
|
||||
VALUES ($1, $2, $3 )
|
||||
ON CONFLICT DO NOTHING;
|
||||
`
|
||||
_, err = db.Exec(statement, email, unsubscribeSecretHex, time.Now().UTC())
|
||||
if err != nil {
|
||||
logger.Errorf("cannot insert email into emails: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
13
api/email_notification.go
Normal file
13
api/email_notification.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
type emailNotification struct {
|
||||
Email string
|
||||
CommenterName string
|
||||
Domain string
|
||||
Path string
|
||||
Title string
|
||||
CommentHex string
|
||||
Kind string
|
||||
}
|
||||
154
api/email_notification_new.go
Normal file
154
api/email_notification_new.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, html string, state string) {
|
||||
if d.EmailNotificationPolicy == "none" {
|
||||
return
|
||||
}
|
||||
|
||||
if d.EmailNotificationPolicy == "pending-moderation" && state == "approved" {
|
||||
return
|
||||
}
|
||||
|
||||
var commenterName string
|
||||
var commenterEmail string
|
||||
if commenterHex == "anonymous" {
|
||||
commenterName = "Anonymous"
|
||||
} else {
|
||||
c, err := commenterGetByHex(commenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get commenter to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
commenterName = c.Name
|
||||
commenterEmail = c.Email
|
||||
}
|
||||
|
||||
kind := d.EmailNotificationPolicy
|
||||
if state != "approved" {
|
||||
kind = "pending-moderation"
|
||||
}
|
||||
|
||||
for _, m := range d.Moderators {
|
||||
// Do not email the commenting moderator their own comment.
|
||||
if commenterHex != "anonymous" && m.Email == commenterEmail {
|
||||
continue
|
||||
}
|
||||
|
||||
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, html string, parentHex string, state string) {
|
||||
// No reply notifications for root comments.
|
||||
if parentHex == "root" {
|
||||
return
|
||||
}
|
||||
|
||||
// No reply notification emails for unapproved comments.
|
||||
if state != "approved" {
|
||||
return
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT commenterHex
|
||||
FROM comments
|
||||
WHERE commentHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, parentHex)
|
||||
|
||||
var parentCommenterHex string
|
||||
err := row.Scan(&parentCommenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot scan commenterHex and parentCommenterHex: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// No reply notification emails for anonymous users.
|
||||
if parentCommenterHex == "anonymous" {
|
||||
return
|
||||
}
|
||||
|
||||
// No reply notification email for self replies.
|
||||
if parentCommenterHex == commenterHex {
|
||||
return
|
||||
}
|
||||
|
||||
pc, err := commenterGetByHex(parentCommenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get commenter to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var commenterName string
|
||||
if commenterHex == "anonymous" {
|
||||
commenterName = "Anonymous"
|
||||
} else {
|
||||
c, err := commenterGetByHex(commenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get commenter to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
commenterName = c.Name
|
||||
}
|
||||
|
||||
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, 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)
|
||||
return
|
||||
}
|
||||
|
||||
if p.Title == "" {
|
||||
p.Title, err = pageTitleUpdate(d.Domain, path)
|
||||
if err != nil {
|
||||
// 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, html, state)
|
||||
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, html, parentHex, state)
|
||||
}
|
||||
39
api/email_update.go
Normal file
39
api/email_update.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func emailUpdate(e email) error {
|
||||
statement := `
|
||||
UPDATE emails
|
||||
SET sendReplyNotifications = $3, sendModeratorNotifications = $4
|
||||
WHERE email = $1 AND unsubscribeSecretHex = $2;
|
||||
`
|
||||
_, err := db.Exec(statement, e.Email, e.UnsubscribeSecretHex, e.SendReplyNotifications, e.SendModeratorNotifications)
|
||||
if err != nil {
|
||||
logger.Errorf("error updating email: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func emailUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
Email *email `json:"email"`
|
||||
}
|
||||
|
||||
var x request
|
||||
if err := bodyUnmarshal(r, &x); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := emailUpdate(*x.Email); err != nil {
|
||||
bodyMarshal(w, response{"success": true, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true})
|
||||
}
|
||||
@@ -37,7 +37,18 @@ 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.")
|
||||
var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.")
|
||||
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
|
||||
var errorDatabaseMigration = errors.New("Encountered error applying database migration.")
|
||||
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.")
|
||||
|
||||
97
api/forgot.go
Normal file
97
api/forgot.go
Normal 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
23
api/go.mod
Normal 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
37
api/go.sum
Normal 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=
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
func main() {
|
||||
exitIfError(loggerCreate())
|
||||
exitIfError(versionPrint())
|
||||
exitIfError(configParse())
|
||||
exitIfError(dbConnect(5))
|
||||
exitIfError(migrate())
|
||||
@@ -11,6 +12,9 @@ func main() {
|
||||
exitIfError(markdownRendererCreate())
|
||||
exitIfError(sigintCleanupSetup())
|
||||
exitIfError(versionCheckStart())
|
||||
exitIfError(domainExportCleanupBegin())
|
||||
exitIfError(viewsCleanupBegin())
|
||||
exitIfError(ssoTokenCleanupBegin())
|
||||
|
||||
exitIfError(routesServe())
|
||||
}
|
||||
|
||||
19
api/oauth.go
19
api/oauth.go
@@ -2,14 +2,27 @@ package main
|
||||
|
||||
import ()
|
||||
|
||||
var configuredOauths []string
|
||||
var googleConfigured bool
|
||||
var twitterConfigured bool
|
||||
var githubConfigured bool
|
||||
var gitlabConfigured bool
|
||||
|
||||
func oauthConfigure() error {
|
||||
configuredOauths = []string{}
|
||||
|
||||
if err := googleOauthConfigure(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := twitterOauthConfigure(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := githubOauthConfigure(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gitlabOauthConfigure(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
43
api/oauth_github.go
Normal file
43
api/oauth_github.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/github"
|
||||
"os"
|
||||
)
|
||||
|
||||
var githubConfig *oauth2.Config
|
||||
|
||||
func githubOauthConfigure() error {
|
||||
githubConfig = nil
|
||||
if os.Getenv("GITHUB_KEY") == "" && os.Getenv("GITHUB_SECRET") == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if os.Getenv("GITHUB_KEY") == "" {
|
||||
logger.Errorf("COMMENTO_GITHUB_KEY not configured, but COMMENTO_GITHUB_SECRET is set")
|
||||
return errorOauthMisconfigured
|
||||
}
|
||||
|
||||
if os.Getenv("GITHUB_SECRET") == "" {
|
||||
logger.Errorf("COMMENTO_GITHUB_SECRET not configured, but COMMENTO_GITHUB_KEY is set")
|
||||
return errorOauthMisconfigured
|
||||
}
|
||||
|
||||
logger.Infof("loading github OAuth config")
|
||||
|
||||
githubConfig = &oauth2.Config{
|
||||
RedirectURL: os.Getenv("ORIGIN") + "/api/oauth/github/callback",
|
||||
ClientID: os.Getenv("GITHUB_KEY"),
|
||||
ClientSecret: os.Getenv("GITHUB_SECRET"),
|
||||
Scopes: []string{
|
||||
"read:user",
|
||||
"user:email",
|
||||
},
|
||||
Endpoint: github.Endpoint,
|
||||
}
|
||||
|
||||
githubConfigured = true
|
||||
|
||||
return nil
|
||||
}
|
||||
131
api/oauth_github_callback.go
Normal file
131
api/oauth_github_callback.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/oauth2"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func githubGetPrimaryEmail(accessToken string) (string, error) {
|
||||
resp, err := http.Get("https://api.github.com/user/emails?access_token=" + accessToken)
|
||||
defer resp.Body.Close()
|
||||
|
||||
contents, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", errorCannotReadResponse
|
||||
}
|
||||
|
||||
user := []map[string]interface{}{}
|
||||
if err := json.Unmarshal(contents, &user); err != nil {
|
||||
logger.Errorf("error unmarshaling github user: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
nonPrimaryEmail := ""
|
||||
for _, email := range user {
|
||||
nonPrimaryEmail = email["email"].(string)
|
||||
if email["primary"].(bool) {
|
||||
return email["email"].(string), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nonPrimaryEmail, nil
|
||||
}
|
||||
|
||||
func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
commenterToken := r.FormValue("state")
|
||||
code := r.FormValue("code")
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
token, err := githubConfig.Exchange(oauth2.NoContext, code)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
email, err := githubGetPrimaryEmail(token.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Get("https://api.github.com/user?access_token=" + token.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contents, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", errorCannotReadResponse.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := make(map[string]interface{})
|
||||
if err := json.Unmarshal(contents, &user); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", errorInternal.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
if user["email"] == nil {
|
||||
fmt.Fprintf(w, "Error: no email address returned by Github")
|
||||
return
|
||||
}
|
||||
|
||||
email = user["email"].(string)
|
||||
}
|
||||
|
||||
name := user["login"].(string)
|
||||
if user["name"] != nil {
|
||||
name = user["name"].(string)
|
||||
}
|
||||
|
||||
link := "undefined"
|
||||
if user["html_url"] != nil {
|
||||
link = user["html_url"].(string)
|
||||
}
|
||||
|
||||
photo := "undefined"
|
||||
if user["avatar_url"] != nil {
|
||||
photo = user["avatar_url"].(string)
|
||||
}
|
||||
|
||||
c, err := commenterGetByEmail("github", email)
|
||||
if err != nil && err != errorNoSuchCommenter {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var commenterHex string
|
||||
|
||||
if err == errorNoSuchCommenter {
|
||||
commenterHex, err = commenterNew(email, name, link, photo, "github", "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
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
|
||||
}
|
||||
|
||||
if err := commenterSessionUpdate(commenterToken, commenterHex); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
|
||||
}
|
||||
25
api/oauth_github_redirect.go
Normal file
25
api/oauth_github_redirect.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func githubRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if githubConfig == nil {
|
||||
logger.Errorf("github oauth access attempt without configuration")
|
||||
fmt.Fprintf(w, "error: this website has not configured github OAuth")
|
||||
return
|
||||
}
|
||||
|
||||
commenterToken := r.FormValue("commenterToken")
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
url := githubConfig.AuthCodeURL(commenterToken)
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
44
api/oauth_gitlab.go
Normal file
44
api/oauth_gitlab.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/gitlab"
|
||||
"os"
|
||||
)
|
||||
|
||||
var gitlabConfig *oauth2.Config
|
||||
|
||||
func gitlabOauthConfigure() error {
|
||||
gitlabConfig = nil
|
||||
if os.Getenv("GITLAB_KEY") == "" && os.Getenv("GITLAB_SECRET") == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if os.Getenv("GITLAB_KEY") == "" {
|
||||
logger.Errorf("COMMENTO_GITLAB_KEY not configured, but COMMENTO_GITLAB_SECRET is set")
|
||||
return errorOauthMisconfigured
|
||||
}
|
||||
|
||||
if os.Getenv("GITLAB_SECRET") == "" {
|
||||
logger.Errorf("COMMENTO_GITLAB_SECRET not configured, but COMMENTO_GITLAB_KEY is set")
|
||||
return errorOauthMisconfigured
|
||||
}
|
||||
|
||||
logger.Infof("loading gitlab OAuth config")
|
||||
|
||||
gitlabConfig = &oauth2.Config{
|
||||
RedirectURL: os.Getenv("ORIGIN") + "/api/oauth/gitlab/callback",
|
||||
ClientID: os.Getenv("GITLAB_KEY"),
|
||||
ClientSecret: os.Getenv("GITLAB_SECRET"),
|
||||
Scopes: []string{
|
||||
"read_user",
|
||||
},
|
||||
Endpoint: gitlab.Endpoint,
|
||||
}
|
||||
gitlabConfig.Endpoint.AuthURL = os.Getenv("GITLAB_URL") + "/oauth/authorize"
|
||||
gitlabConfig.Endpoint.TokenURL = os.Getenv("GITLAB_URL") + "/oauth/token"
|
||||
|
||||
gitlabConfigured = true
|
||||
|
||||
return nil
|
||||
}
|
||||
101
api/oauth_gitlab_callback.go
Normal file
101
api/oauth_gitlab_callback.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/oauth2"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func gitlabCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
commenterToken := r.FormValue("state")
|
||||
code := r.FormValue("code")
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
token, err := gitlabConfig.Exchange(oauth2.NoContext, code)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
logger.Infof("%v", resp.StatusCode)
|
||||
defer resp.Body.Close()
|
||||
|
||||
contents, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", errorCannotReadResponse.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := make(map[string]interface{})
|
||||
if err := json.Unmarshal(contents, &user); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", errorInternal.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if user["email"] == nil {
|
||||
fmt.Fprintf(w, "Error: no email address returned by Gitlab")
|
||||
return
|
||||
}
|
||||
|
||||
email := user["email"].(string)
|
||||
|
||||
if user["name"] == nil {
|
||||
fmt.Fprintf(w, "Error: no name returned by Gitlab")
|
||||
return
|
||||
}
|
||||
|
||||
name := user["name"].(string)
|
||||
|
||||
link := "undefined"
|
||||
if user["web_url"] != nil {
|
||||
link = user["web_url"].(string)
|
||||
}
|
||||
|
||||
photo := "undefined"
|
||||
if user["avatar_url"] != nil {
|
||||
photo = user["avatar_url"].(string)
|
||||
}
|
||||
|
||||
c, err := commenterGetByEmail("gitlab", email)
|
||||
if err != nil && err != errorNoSuchCommenter {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var commenterHex string
|
||||
|
||||
if err == errorNoSuchCommenter {
|
||||
commenterHex, err = commenterNew(email, name, link, photo, "gitlab", "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
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
|
||||
}
|
||||
|
||||
if err := commenterSessionUpdate(commenterToken, commenterHex); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
|
||||
}
|
||||
25
api/oauth_gitlab_redirect.go
Normal file
25
api/oauth_gitlab_redirect.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func gitlabRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if gitlabConfig == nil {
|
||||
logger.Errorf("gitlab oauth access attempt without configuration")
|
||||
fmt.Fprintf(w, "error: this website has not configured gitlab OAuth")
|
||||
return
|
||||
}
|
||||
|
||||
commenterToken := r.FormValue("commenterToken")
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
url := gitlabConfig.AuthCodeURL(commenterToken)
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func googleOauthConfigure() error {
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
|
||||
configuredOauths = append(configuredOauths, "google")
|
||||
googleConfigured = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,37 +39,45 @@ func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
c, err := commenterGetByEmail("google", user["email"].(string))
|
||||
if user["email"] == nil {
|
||||
fmt.Fprintf(w, "Error: no email address returned by Github")
|
||||
return
|
||||
}
|
||||
|
||||
email := user["email"].(string)
|
||||
|
||||
c, err := commenterGetByEmail("google", email)
|
||||
if err != nil && err != errorNoSuchCommenter {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
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 email string
|
||||
if _, ok := user["email"]; ok {
|
||||
email = user["email"].(string)
|
||||
} else {
|
||||
fmt.Fprintf(w, "Error: %s", errorInvalidEmail.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var link string
|
||||
if val, ok := user["link"]; ok {
|
||||
link = val.(string)
|
||||
} else {
|
||||
link = "undefined"
|
||||
}
|
||||
|
||||
commenterHex, err = commenterNew(email, user["name"].(string), link, user["picture"].(string), "google", "")
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
61
api/oauth_sso.go
Normal file
61
api/oauth_sso.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ssoPayload struct {
|
||||
Domain string `json:"domain"`
|
||||
Token string `json:"token"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Photo string `json:"photo"`
|
||||
}
|
||||
|
||||
func ssoTokenNew(domain string, commenterToken string) (string, error) {
|
||||
token, err := randomHex(32)
|
||||
if err != nil {
|
||||
logger.Errorf("error generating SSO token hex: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
statement := `
|
||||
INSERT INTO
|
||||
ssoTokens (token, domain, commenterToken, creationDate)
|
||||
VALUES ($1, $2, $3, $4 );
|
||||
`
|
||||
_, err = db.Exec(statement, token, domain, commenterToken, time.Now().UTC())
|
||||
if err != nil {
|
||||
logger.Errorf("error inserting SSO token: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func ssoTokenExtract(token string) (string, string, error) {
|
||||
statement := `
|
||||
SELECT domain, commenterToken
|
||||
FROM ssoTokens
|
||||
WHERE token = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, token)
|
||||
|
||||
var domain string
|
||||
var commenterToken string
|
||||
if err := row.Scan(&domain, &commenterToken); err != nil {
|
||||
return "", "", errorNoSuchToken
|
||||
}
|
||||
|
||||
statement = `
|
||||
DELETE FROM ssoTokens
|
||||
WHERE token = $1;
|
||||
`
|
||||
if _, err := db.Exec(statement, token); err != nil {
|
||||
logger.Errorf("cannot delete SSO token after usage: %v", err)
|
||||
return "", "", errorInternal
|
||||
}
|
||||
|
||||
return domain, commenterToken, nil
|
||||
}
|
||||
120
api/oauth_sso_callback.go
Normal file
120
api/oauth_sso_callback.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func ssoCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
payloadHex := r.FormValue("payload")
|
||||
signature := r.FormValue("hmac")
|
||||
|
||||
payloadBytes, err := hex.DecodeString(payloadHex)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: invalid JSON payload hex encoding: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
signatureBytes, err := hex.DecodeString(signature)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: invalid HMAC signature hex encoding: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
payload := ssoPayload{}
|
||||
err = json.Unmarshal(payloadBytes, &payload)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: cannot unmarshal JSON payload: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Token == "" || payload.Email == "" || payload.Name == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Link == "" {
|
||||
payload.Link = "undefined"
|
||||
}
|
||||
|
||||
if payload.Photo == "" {
|
||||
payload.Photo = "undefined"
|
||||
}
|
||||
|
||||
domain, commenterToken, err := ssoTokenExtract(payload.Token)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
d, err := domainGet(domain)
|
||||
if err != nil {
|
||||
if err == errorNoSuchDomain {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
} else {
|
||||
logger.Errorf("cannot get domain for SSO: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if d.SsoSecret == "" || d.SsoUrl == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error())
|
||||
return
|
||||
}
|
||||
|
||||
key, err := hex.DecodeString(d.SsoSecret)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot decode SSO secret as hex: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(payloadBytes)
|
||||
expectedSignatureBytes := h.Sum(nil)
|
||||
if !hmac.Equal(expectedSignatureBytes, signatureBytes) {
|
||||
fmt.Fprintf(w, "Error: HMAC signature verification failed\n")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c, err := commenterGetByEmail("sso:"+domain, payload.Email)
|
||||
if err != nil && err != errorNoSuchCommenter {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var commenterHex string
|
||||
|
||||
if err == errorNoSuchCommenter {
|
||||
commenterHex, err = commenterNew(payload.Email, payload.Name, payload.Link, payload.Photo, "sso:"+domain, "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
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
|
||||
}
|
||||
|
||||
if err = commenterSessionUpdate(commenterToken, commenterHex); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
|
||||
}
|
||||
88
api/oauth_sso_redirect.go
Normal file
88
api/oauth_sso_redirect.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func ssoRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
commenterToken := r.FormValue("commenterToken")
|
||||
domain := r.Header.Get("Referer")
|
||||
|
||||
if commenterToken == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error())
|
||||
return
|
||||
}
|
||||
|
||||
domain = domainStrip(domain)
|
||||
if domain == "" {
|
||||
fmt.Fprintf(w, "Error: No Referer header found in request\n")
|
||||
return
|
||||
}
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
d, err := domainGet(domain)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorNoSuchDomain.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !d.SsoProvider {
|
||||
fmt.Fprintf(w, "Error: SSO not configured for %s\n", domain)
|
||||
return
|
||||
}
|
||||
|
||||
if d.SsoSecret == "" || d.SsoUrl == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error())
|
||||
return
|
||||
}
|
||||
|
||||
key, err := hex.DecodeString(d.SsoSecret)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot decode SSO secret as hex: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
token, err := ssoTokenNew(domain, commenterToken)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tokenBytes, err := hex.DecodeString(token)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot decode hex token: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(tokenBytes)
|
||||
signature := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
u, err := url.Parse(d.SsoUrl)
|
||||
if err != nil {
|
||||
// this should really not be happening; we're checking if the
|
||||
// passed URL is valid at domain update
|
||||
logger.Errorf("cannot parse URL: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
|
||||
return
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("token", token)
|
||||
q.Set("hmac", signature)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||
}
|
||||
51
api/oauth_twitter.go
Normal file
51
api/oauth_twitter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gomodule/oauth1/oauth"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type twitterOauthState struct {
|
||||
CommenterToken string
|
||||
Cred *oauth.Credentials
|
||||
}
|
||||
|
||||
var twitterClient *oauth.Client
|
||||
var twitterCredMapLock sync.RWMutex
|
||||
var twitterCredMap map[string]twitterOauthState
|
||||
|
||||
func twitterOauthConfigure() error {
|
||||
twitterClient = nil
|
||||
if os.Getenv("TWITTER_KEY") == "" && os.Getenv("TWITTER_SECRET") == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if os.Getenv("TWITTER_KEY") == "" {
|
||||
logger.Errorf("COMMENTO_TWITTER_KEY not configured, but COMMENTO_TWITTER_SECRET is set")
|
||||
return errorOauthMisconfigured
|
||||
}
|
||||
|
||||
if os.Getenv("TWITTER_SECRET") == "" {
|
||||
logger.Errorf("COMMENTO_TWITTER_SECRET not configured, but COMMENTO_TWITTER_KEY is set")
|
||||
return errorOauthMisconfigured
|
||||
}
|
||||
|
||||
logger.Infof("loading twitter OAuth config")
|
||||
|
||||
twitterClient = &oauth.Client{
|
||||
TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token",
|
||||
ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authenticate",
|
||||
TokenRequestURI: "https://api.twitter.com/oauth/access_token",
|
||||
Credentials: oauth.Credentials{
|
||||
Token: os.Getenv("TWITTER_KEY"),
|
||||
Secret: os.Getenv("TWITTER_SECRET"),
|
||||
},
|
||||
}
|
||||
|
||||
twitterCredMap = make(map[string]twitterOauthState, 1e3)
|
||||
|
||||
twitterConfigured = true
|
||||
|
||||
return nil
|
||||
}
|
||||
135
api/oauth_twitter_callback.go
Normal file
135
api/oauth_twitter_callback.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func twitterCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.FormValue("oauth_token")
|
||||
verifier := r.FormValue("oauth_verifier")
|
||||
|
||||
twitterCredMapLock.RLock()
|
||||
s, ok := twitterCredMap[token]
|
||||
twitterCredMapLock.RUnlock()
|
||||
|
||||
commenterToken := s.CommenterToken
|
||||
|
||||
if !ok {
|
||||
fmt.Fprintf(w, "no such token/verifier combination found")
|
||||
return
|
||||
}
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
x, _, err := twitterClient.RequestToken(nil, s.Cred, verifier)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
twitterCredMapLock.Lock()
|
||||
delete(twitterCredMap, token)
|
||||
twitterCredMapLock.Unlock()
|
||||
|
||||
resp, err := twitterClient.Get(nil, x, "https://api.twitter.com/1.1/account/verify_credentials.json", url.Values{"include_email": {"true"}})
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error getting email: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
msg, _ := ioutil.ReadAll(resp.Body)
|
||||
fmt.Fprintf(w, "Error: status %d: %s\n", resp.StatusCode, msg)
|
||||
return
|
||||
}
|
||||
|
||||
var res twitterOAuthReponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
if err := res.validate(); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
email := res.Email
|
||||
name := res.Name
|
||||
link := res.getLinkURL()
|
||||
photo := res.getImageURL()
|
||||
|
||||
c, err := commenterGetByEmail("twitter", email)
|
||||
if err != nil && err != errorNoSuchCommenter {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var commenterHex string
|
||||
|
||||
if err == errorNoSuchCommenter {
|
||||
commenterHex, err = commenterNew(email, name, link, photo, "twitter", "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
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
|
||||
}
|
||||
|
||||
if err := commenterSessionUpdate(commenterToken, commenterHex); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
39
api/oauth_twitter_redirect.go
Normal file
39
api/oauth_twitter_redirect.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func twitterRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if twitterClient == nil {
|
||||
logger.Errorf("twitter oauth access attempt without configuration")
|
||||
fmt.Fprintf(w, "error: this website has not configured twitter OAuth")
|
||||
return
|
||||
}
|
||||
|
||||
commenterToken := r.FormValue("commenterToken")
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := twitterClient.RequestTemporaryCredentials(nil, os.Getenv("ORIGIN")+"/api/oauth/twitter/callback", nil)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get temporary twitter credentials: %v", err)
|
||||
fmt.Fprintf(w, "error: %v", errorInternal.Error())
|
||||
return
|
||||
}
|
||||
|
||||
twitterCredMapLock.Lock()
|
||||
twitterCredMap[cred.Token] = twitterOauthState{
|
||||
CommenterToken: commenterToken,
|
||||
Cred: cred,
|
||||
}
|
||||
twitterCredMapLock.Unlock()
|
||||
|
||||
http.Redirect(w, r, twitterClient.AuthorizationURL(cred, nil), http.StatusFound)
|
||||
}
|
||||
@@ -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
79
api/owner_delete.go
Normal 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})
|
||||
}
|
||||
@@ -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,38 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT ownerHex, email, name, confirmedEmail, joinDate
|
||||
SELECT ` + ownersRowColumns + `
|
||||
FROM owners
|
||||
WHERE email IN (
|
||||
SELECT email 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
|
||||
}
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func ownerGetByOwnerHex(ownerHex string) (owner, error) {
|
||||
if ownerHex == "" {
|
||||
return owner{}, errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT ` + ownersRowColumns + `
|
||||
FROM owners
|
||||
WHERE ownerHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, ownerHex)
|
||||
|
||||
var o owner
|
||||
if err := ownersRowScan(row, &o); err != nil {
|
||||
logger.Errorf("cannot scan owner: %v\n", err)
|
||||
return owner{}, errorInternal
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ func ownerNew(email string, name string, password string) (string, error) {
|
||||
return "", errorNewOwnerForbidden
|
||||
}
|
||||
|
||||
if _, err := ownerGetByEmail(email); err == nil {
|
||||
return "", errorEmailAlreadyExists
|
||||
}
|
||||
|
||||
if err := emailNew(email); err != nil {
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
ownerHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot generate ownerHex: %v", err)
|
||||
@@ -48,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)
|
||||
@@ -84,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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -1,72 +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 email IN (
|
||||
SELECT email FROM ownerResetHexes
|
||||
WHERE resetHex=$2
|
||||
);
|
||||
`
|
||||
res, err := db.Exec(statement, string(passwordHash), resetHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot change user's password: %v\n", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
count, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
logger.Errorf("cannot count rows affected: %v\n", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return errorNoSuchResetToken
|
||||
}
|
||||
|
||||
statement = `
|
||||
DELETE FROM ownerResetHexes
|
||||
WHERE resetHex=$1;
|
||||
`
|
||||
_, err = db.Exec(statement, resetHex)
|
||||
if err != nil {
|
||||
logger.Warningf("cannot remove reset token: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ownerResetPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
ResetHex *string `json:"resetHex"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
|
||||
var x request
|
||||
if err := 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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
12
api/page.go
Normal file
12
api/page.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
type page struct {
|
||||
Domain string `json:"domain"`
|
||||
Path string `json:"path"`
|
||||
IsLocked bool `json:"isLocked"`
|
||||
CommentCount int `json:"commentCount"`
|
||||
StickyCommentHex string `json:"stickyCommentHex"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
37
api/page_get.go
Normal file
37
api/page_get.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
func pageGet(domain string, path string) (page, error) {
|
||||
// path can be empty
|
||||
if domain == "" {
|
||||
return page{}, errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT isLocked, commentCount, stickyCommentHex, title
|
||||
FROM pages
|
||||
WHERE domain=$1 AND path=$2;
|
||||
`
|
||||
row := db.QueryRow(statement, domain, path)
|
||||
|
||||
p := page{Domain: domain, Path: path}
|
||||
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex, &p.Title); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// If there haven't been any comments, there won't be a record for this
|
||||
// page. The sane thing to do is return defaults.
|
||||
// TODO: the defaults are hard-coded in two places: here and the schema
|
||||
p.IsLocked = false
|
||||
p.CommentCount = 0
|
||||
p.StickyCommentHex = "none"
|
||||
p.Title = ""
|
||||
} else {
|
||||
logger.Errorf("error scanning page: %v", err)
|
||||
return page{}, errorInternal
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
43
api/page_get_test.go
Normal file
43
api/page_get_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPageGetBasics(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
pageNew("example.com", "/path.html")
|
||||
|
||||
p, err := pageGet("example.com", "/path.html")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error getting page: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if p.IsLocked != false {
|
||||
t.Errorf("expected p.IsLocked=false got %v", p.IsLocked)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := pageGet("example.com", "/path2.html"); err != nil {
|
||||
t.Errorf("unexpected error getting page with non-existant record: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageGetEmpty(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
pageNew("example.com", "")
|
||||
|
||||
if _, err := pageGet("example.com", ""); err != nil {
|
||||
t.Errorf("unexpected error getting page with empty path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := pageGet("", "/path.html"); err == nil {
|
||||
t.Errorf("exepected error not found when getting page with empty domain")
|
||||
return
|
||||
}
|
||||
}
|
||||
24
api/page_new.go
Normal file
24
api/page_new.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func pageNew(domain string, path string) error {
|
||||
// path can be empty
|
||||
if domain == "" {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
INSERT INTO
|
||||
pages (domain, path)
|
||||
VALUES ($1, $2 )
|
||||
ON CONFLICT DO NOTHING;
|
||||
`
|
||||
_, err := db.Exec(statement, domain, path)
|
||||
if err != nil {
|
||||
logger.Errorf("error inserting new page: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
43
api/page_new_test.go
Normal file
43
api/page_new_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPageNewBasics(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
if err := pageNew("example.com", "/path.html"); err != nil {
|
||||
t.Errorf("unexpected error creating page: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageNewEmpty(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
if err := pageNew("example.com", ""); err != nil {
|
||||
t.Errorf("unexpected error creating page with empty path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pageNew("", "/path.html"); err == nil {
|
||||
t.Errorf("expected error not found creating page with empty domain")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageNewUnique(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
if err := pageNew("example.com", "/path.html"); err != nil {
|
||||
t.Errorf("unexpected error creating page: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// no error should be returned when trying to duplicate insert
|
||||
if err := pageNew("example.com", "/path.html"); err != nil {
|
||||
t.Errorf("unexpected error creating same page twice: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
28
api/page_title.go
Normal file
28
api/page_title.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func pageTitleUpdate(domain string, path string) (string, error) {
|
||||
title, err := htmlTitleGet("http://" + domain + path)
|
||||
if err != nil {
|
||||
// This could fail due to a variety of reasons that we can't control such
|
||||
// as the user's URL 404 or something, so let's not pollute the error log
|
||||
// with messages. Just use a sane title. Maybe we'll have the ability to
|
||||
// retry later.
|
||||
logger.Errorf("%v", err)
|
||||
title = domain
|
||||
}
|
||||
|
||||
statement := `
|
||||
UPDATE pages
|
||||
SET title = $3
|
||||
WHERE domain = $1 AND path = $2;
|
||||
`
|
||||
_, err = db.Exec(statement, domain, path, title)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update pages table with title: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return title, nil
|
||||
}
|
||||
72
api/page_update.go
Normal file
72
api/page_update.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func pageUpdate(p page) error {
|
||||
if p.Domain == "" {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
// fields to not update:
|
||||
// commentCount
|
||||
statement := `
|
||||
INSERT INTO
|
||||
pages (domain, path, isLocked, stickyCommentHex)
|
||||
VALUES ($1, $2, $3, $4 )
|
||||
ON CONFLICT (domain, path) DO
|
||||
UPDATE SET isLocked = $3, stickyCommentHex = $4;
|
||||
`
|
||||
_, err := db.Exec(statement, p.Domain, p.Path, p.IsLocked, p.StickyCommentHex)
|
||||
if err != nil {
|
||||
logger.Errorf("error setting page attributes: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pageUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
CommenterToken *string `json:"commenterToken"`
|
||||
Domain *string `json:"domain"`
|
||||
Path *string `json:"path"`
|
||||
Attributes *page `json:"attributes"`
|
||||
}
|
||||
|
||||
var x request
|
||||
if err := bodyUnmarshal(r, &x); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
domain := domainStrip(*x.Domain)
|
||||
|
||||
isModerator, err := isDomainModerator(domain, c.Email)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !isModerator {
|
||||
bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
(*x.Attributes).Domain = *x.Domain
|
||||
(*x.Attributes).Path = *x.Path
|
||||
|
||||
if err = pageUpdate(*x.Attributes); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true})
|
||||
}
|
||||
43
api/page_update_test.go
Normal file
43
api/page_update_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPageUpdateBasics(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
commenterHex, _ := commenterNew("test@example.com", "Test", "undefined", "https://example.com/photo.jpg", "google", "")
|
||||
|
||||
commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "unapproved", time.Now().UTC())
|
||||
|
||||
p, _ := pageGet("example.com", "/path.html")
|
||||
if p.IsLocked != false {
|
||||
t.Errorf("expected IsLocked=false got %v", p.IsLocked)
|
||||
return
|
||||
}
|
||||
|
||||
p.IsLocked = true
|
||||
|
||||
if err := pageUpdate(p); err != nil {
|
||||
t.Errorf("unexpected error updating page: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
p, _ = pageGet("example.com", "/path.html")
|
||||
if p.IsLocked != true {
|
||||
t.Errorf("expected IsLocked=true got %v", p.IsLocked)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageUpdateEmpty(t *testing.T) {
|
||||
failTestOnError(t, setupTestEnv())
|
||||
|
||||
p := page{Domain: "", Path: "", IsLocked: false}
|
||||
if err := pageUpdate(p); err == nil {
|
||||
t.Errorf("expected error not found updating page with empty everything")
|
||||
return
|
||||
}
|
||||
}
|
||||
82
api/reset.go
Normal file
82
api/reset.go
Normal 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})
|
||||
}
|
||||
@@ -8,32 +8,61 @@ 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")
|
||||
router.HandleFunc("/api/domain/clear", domainClearHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/sso/new", domainSsoSecretNewHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
|
||||
router.HandleFunc("/api/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")
|
||||
|
||||
router.HandleFunc("/api/commenter/token/new", commenterTokenNewHandler).Methods("GET")
|
||||
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")
|
||||
|
||||
router.HandleFunc("/api/oauth/google/redirect", googleRedirectHandler).Methods("GET")
|
||||
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/oauth/github/redirect", githubRedirectHandler).Methods("GET")
|
||||
router.HandleFunc("/api/oauth/github/callback", githubCallbackHandler).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/oauth/twitter/redirect", twitterRedirectHandler).Methods("GET")
|
||||
router.HandleFunc("/api/oauth/twitter/callback", twitterCallbackHandler).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/oauth/gitlab/redirect", gitlabRedirectHandler).Methods("GET")
|
||||
router.HandleFunc("/api/oauth/gitlab/callback", gitlabCallbackHandler).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/oauth/sso/redirect", ssoRedirectHandler).Methods("GET")
|
||||
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")
|
||||
router.HandleFunc("/api/comment/approve", commentApproveHandler).Methods("POST")
|
||||
router.HandleFunc("/api/comment/delete", commentDeleteHandler).Methods("POST")
|
||||
|
||||
router.HandleFunc("/api/page/update", pageUpdateHandler).Methods("POST")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,39 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func redirectLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, os.Getenv("ORIGIN")+"/login", 301)
|
||||
}
|
||||
|
||||
type staticAssetPlugs struct {
|
||||
Origin string
|
||||
}
|
||||
|
||||
type staticHtmlPlugs struct {
|
||||
type staticPlugs struct {
|
||||
Origin string
|
||||
CdnPrefix string
|
||||
Footer template.HTML
|
||||
Footer string
|
||||
}
|
||||
|
||||
var asset map[string][]byte = make(map[string][]byte)
|
||||
var contentType map[string]string = make(map[string]string)
|
||||
var footer string
|
||||
var compress bool
|
||||
|
||||
func fileDetemplate(f string) ([]byte, error) {
|
||||
contents, err := ioutil.ReadFile(f)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot read file %s: %v", f, err)
|
||||
return []byte{}, err
|
||||
}
|
||||
|
||||
x := string(contents)
|
||||
x = strings.Replace(x, "[[[.Origin]]]", os.Getenv("ORIGIN"), -1)
|
||||
x = strings.Replace(x, "[[[.CdnPrefix]]]", os.Getenv("CDN_PREFIX"), -1)
|
||||
x = strings.Replace(x, "[[[.Footer]]]", footer, -1)
|
||||
x = strings.Replace(x, "[[[.Version]]]", version, -1)
|
||||
|
||||
return []byte(x), nil
|
||||
}
|
||||
|
||||
func footerInit() error {
|
||||
contents, err := fileDetemplate(os.Getenv("STATIC") + "/footer.html")
|
||||
if err != nil {
|
||||
logger.Errorf("cannot init footer: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
footer = string(contents)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileLoad(f string) ([]byte, error) {
|
||||
b, err := fileDetemplate(f)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot load file %s: %v", f, err)
|
||||
return []byte{}, err
|
||||
}
|
||||
|
||||
if !compress {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
return gzipStatic(b)
|
||||
}
|
||||
|
||||
func staticRouterInit(router *mux.Router) error {
|
||||
asset := make(map[string][]byte)
|
||||
gzippedAsset := make(map[string][]byte)
|
||||
var err error
|
||||
|
||||
for _, dir := range []string{"js", "css", "images"} {
|
||||
sl := string(os.PathSeparator)
|
||||
dir = sl + dir
|
||||
subdir := pathStrip(os.Getenv("ORIGIN"))
|
||||
|
||||
if err = footerInit(); err != nil {
|
||||
logger.Errorf("error initialising static router: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, dir := range []string{"/js", "/css", "/images", "/fonts"} {
|
||||
files, err := ioutil.ReadDir(os.Getenv("STATIC") + dir)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot read directory %s%s: %v", os.Getenv("STATIC"), dir, err)
|
||||
@@ -41,98 +84,50 @@ func staticRouterInit(router *mux.Router) error {
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
p := dir + sl + file.Name()
|
||||
|
||||
contents, err := ioutil.ReadFile(os.Getenv("STATIC") + p)
|
||||
f := dir + "/" + file.Name()
|
||||
asset[subdir+f], err = fileLoad(os.Getenv("STATIC") + f)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot read file %s%s: %v", os.Getenv("STATIC"), p, err)
|
||||
logger.Errorf("cannot detemplate %s%s: %v", os.Getenv("STATIC"), f, err)
|
||||
return err
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if dir == "/js" {
|
||||
prefix = "window.commentoOrigin='" + os.Getenv("ORIGIN") + "';\n"
|
||||
prefix += "window.commentoCdn='" + os.Getenv("CDN_PREFIX") + "';\n"
|
||||
}
|
||||
|
||||
gzip := (os.Getenv("GZIP_STATIC") == "true")
|
||||
|
||||
subdir := pathStrip(os.Getenv("ORIGIN"))
|
||||
|
||||
asset[subdir+p] = []byte(prefix + string(contents))
|
||||
if gzip {
|
||||
gzippedAsset[subdir+p], err = gzipStatic(asset[subdir+p])
|
||||
if err != nil {
|
||||
logger.Errorf("error gzipping %s: %v", p, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// faster than checking inside the handler
|
||||
if !gzip {
|
||||
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
|
||||
w.Write(asset[r.URL.Path])
|
||||
})
|
||||
} else {
|
||||
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Write(gzippedAsset[r.URL.Path])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer, err := ioutil.ReadFile(os.Getenv("STATIC") + string(os.PathSeparator) + "footer.html")
|
||||
if err != nil {
|
||||
logger.Errorf("cannot read file footer.html: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
pages := []string{
|
||||
"login",
|
||||
"forgot",
|
||||
"reset-password",
|
||||
"signup",
|
||||
"confirm-email",
|
||||
"dashboard",
|
||||
"logout",
|
||||
}
|
||||
|
||||
html := make(map[string]string)
|
||||
for _, page := range pages {
|
||||
sl := string(os.PathSeparator)
|
||||
page = sl + page
|
||||
file := page + ".html"
|
||||
|
||||
contents, err := ioutil.ReadFile(os.Getenv("STATIC") + file)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot read file %s%s: %v", os.Getenv("STATIC"), file, err)
|
||||
return err
|
||||
}
|
||||
|
||||
t, err := template.New(page).Delims("[[[", "]]]").Parse(string(contents))
|
||||
if err != nil {
|
||||
logger.Errorf("cannot parse %s%s template: %v", os.Getenv("STATIC"), file, err)
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
t.Execute(&buf, &staticHtmlPlugs{
|
||||
Origin: os.Getenv("ORIGIN"),
|
||||
CdnPrefix: os.Getenv("CDN_PREFIX"),
|
||||
Footer: template.HTML(string(footer)),
|
||||
})
|
||||
|
||||
subdir := pathStrip(os.Getenv("ORIGIN"))
|
||||
|
||||
html[subdir+page] = buf.String()
|
||||
"/login",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/signup",
|
||||
"/confirm-email",
|
||||
"/unsubscribe",
|
||||
"/dashboard",
|
||||
"/settings",
|
||||
"/logout",
|
||||
"/profile",
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
router.HandleFunc("/"+page, func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, html[r.URL.Path])
|
||||
f := page + ".html"
|
||||
asset[subdir+page], err = fileLoad(os.Getenv("STATIC") + f)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot detemplate %s%s: %v", os.Getenv("STATIC"), f, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for p, _ := range asset {
|
||||
if path.Ext(p) != "" {
|
||||
contentType[p] = mime.TypeByExtension(path.Ext(p))
|
||||
} else {
|
||||
contentType[p] = "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", contentType[r.URL.Path])
|
||||
if compress {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
}
|
||||
w.Write(asset[r.URL.Path])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@ import (
|
||||
)
|
||||
|
||||
func sigintCleanup() int {
|
||||
// TODO: close the database connection and do other cleanup jobs
|
||||
if db != nil {
|
||||
err := db.Close()
|
||||
if err == nil {
|
||||
logger.Errorf("cannot close database connection: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ func smtpConfigure() error {
|
||||
password := os.Getenv("SMTP_PASSWORD")
|
||||
host := os.Getenv("SMTP_HOST")
|
||||
port := os.Getenv("SMTP_PORT")
|
||||
if username == "" || password == "" || host == "" || port == "" {
|
||||
if host == "" || port == "" {
|
||||
logger.Warningf("smtp not configured, no emails will be sent")
|
||||
smtpConfigured = false
|
||||
return nil
|
||||
@@ -26,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
|
||||
}
|
||||
|
||||
29
api/smtp_domain_export.go
Normal file
29
api/smtp_domain_export.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/smtp"
|
||||
"os"
|
||||
)
|
||||
|
||||
type domainExportPlugs struct {
|
||||
Origin string
|
||||
Domain string
|
||||
ExportHex string
|
||||
}
|
||||
|
||||
func smtpDomainExport(to string, toName string, domain string, exportHex string) error {
|
||||
var header bytes.Buffer
|
||||
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Commento Data Export"})
|
||||
|
||||
var body bytes.Buffer
|
||||
templates["domain-export"].Execute(&body, &domainExportPlugs{Origin: os.Getenv("ORIGIN"), ExportHex: exportHex})
|
||||
|
||||
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 {
|
||||
logger.Errorf("cannot send data export email: %v", err)
|
||||
return errorCannotSendEmail
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user