Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4c5cc8b9e | ||
|
|
fc83eed221 | ||
|
|
18612933f6 | ||
|
|
aaa44a0bee | ||
|
|
84bfd64e32 | ||
|
|
800902640b | ||
|
|
5390c6f81c | ||
|
|
326601394a | ||
|
|
3c3cf08656 | ||
|
|
e44ae1ce9d | ||
|
|
025bb10c0b | ||
|
|
9a4563fdb3 | ||
|
|
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 |
@@ -1,24 +1,11 @@
|
|||||||
stages:
|
stages:
|
||||||
- check-dco
|
|
||||||
- go-fmt
|
- go-fmt
|
||||||
- go-test
|
- go-test
|
||||||
- build-src
|
- build-src
|
||||||
- aws-upload-tags
|
|
||||||
- build-docker
|
- build-docker
|
||||||
- docker-registry-master
|
- docker-registry-master
|
||||||
- docker-registry-tags
|
- docker-registry-tags
|
||||||
|
|
||||||
check-dco:
|
|
||||||
stage: check-dco
|
|
||||||
image: debian:buster
|
|
||||||
except:
|
|
||||||
- master
|
|
||||||
- tags
|
|
||||||
script:
|
|
||||||
- apt update
|
|
||||||
- apt install -y curl git jq
|
|
||||||
- bash ./scripts/check-dco
|
|
||||||
|
|
||||||
build-src:
|
build-src:
|
||||||
stage: build-src
|
stage: build-src
|
||||||
image: debian:buster
|
image: debian:buster
|
||||||
@@ -34,25 +21,6 @@ build-src:
|
|||||||
- make devel
|
- make devel
|
||||||
- make prod
|
- make prod
|
||||||
|
|
||||||
aws-upload-tags:
|
|
||||||
stage: aws-upload-tags
|
|
||||||
image: debian:buster
|
|
||||||
environment: aws-upload-tags
|
|
||||||
variables:
|
|
||||||
AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
|
|
||||||
AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
|
|
||||||
only:
|
|
||||||
- tags
|
|
||||||
before_script:
|
|
||||||
- bash $CI_PROJECT_DIR/scripts/gitlab-ci-build-prescript
|
|
||||||
script:
|
|
||||||
- export GOPATH=/go
|
|
||||||
- export PATH=$PATH:/go/bin
|
|
||||||
- cd /go/src/$CI_PROJECT_NAME
|
|
||||||
- make prod
|
|
||||||
- cd build/prod && tar -zcvf /commento-linux-amd64-$(git describe --tags).tgz .
|
|
||||||
- aws s3 cp /commento-linux-amd64-$(git describe --tags).tgz s3://commento-release/
|
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
stage: build-docker
|
stage: build-docker
|
||||||
image: docker:stable
|
image: docker:stable
|
||||||
@@ -66,9 +34,9 @@ build-docker:
|
|||||||
|
|
||||||
go-test:
|
go-test:
|
||||||
stage: go-test
|
stage: go-test
|
||||||
image: golang:1.10.2
|
image: golang:1.14
|
||||||
services:
|
services:
|
||||||
- postgres:latest
|
- postgres:9.6
|
||||||
variables:
|
variables:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
@@ -81,7 +49,6 @@ go-test:
|
|||||||
- mkdir -p /go/src /go/bin /go/pkg
|
- mkdir -p /go/src /go/bin /go/pkg
|
||||||
- export GOPATH=/go
|
- export GOPATH=/go
|
||||||
- export PATH=$PATH:/go/bin
|
- export PATH=$PATH:/go/bin
|
||||||
- curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
|
|
||||||
- ln -s $CI_PROJECT_DIR /go/src/$CI_PROJECT_NAME
|
- ln -s $CI_PROJECT_DIR /go/src/$CI_PROJECT_NAME
|
||||||
script:
|
script:
|
||||||
- cd /go/src/$CI_PROJECT_NAME
|
- cd /go/src/$CI_PROJECT_NAME
|
||||||
@@ -89,7 +56,7 @@ go-test:
|
|||||||
|
|
||||||
go-fmt:
|
go-fmt:
|
||||||
stage: go-fmt
|
stage: go-fmt
|
||||||
image: golang:1.10.2
|
image: golang:1.14
|
||||||
except:
|
except:
|
||||||
- master
|
- master
|
||||||
- tags
|
- tags
|
||||||
|
|||||||
67
Dockerfile
67
Dockerfile
@@ -1,66 +1,53 @@
|
|||||||
# backend build (api server)
|
# backend build (api server)
|
||||||
FROM golang:1.10.2-alpine AS api-build
|
FROM golang:1.15-alpine AS api-build
|
||||||
|
RUN apk add --no-cache --update bash dep make git curl g++
|
||||||
|
|
||||||
COPY ./api /go/src/commento/api
|
ARG RELEASE=prod
|
||||||
|
COPY ./api /go/src/commento/api/
|
||||||
WORKDIR /go/src/commento/api
|
WORKDIR /go/src/commento/api
|
||||||
|
RUN make ${RELEASE} -j$(($(nproc) + 1))
|
||||||
RUN apk update && apk add bash make git curl
|
|
||||||
RUN curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
|
|
||||||
|
|
||||||
RUN make prod -j$(($(nproc) + 1))
|
|
||||||
|
|
||||||
|
|
||||||
# frontend build (html, js, css, images)
|
# frontend build (html, js, css, images)
|
||||||
FROM node:10.3.0-alpine AS frontend-build
|
FROM node:12-alpine AS frontend-build
|
||||||
|
RUN apk add --no-cache --update bash make python2 g++
|
||||||
|
|
||||||
COPY ./frontend /commento/frontend/
|
ARG RELEASE=prod
|
||||||
|
COPY ./frontend /commento/frontend
|
||||||
WORKDIR /commento/frontend/
|
WORKDIR /commento/frontend/
|
||||||
|
RUN make ${RELEASE} -j$(($(nproc) + 1))
|
||||||
RUN apk update && apk add bash make
|
|
||||||
RUN npm install -g html-minifier@3.5.7 uglify-js@3.4.1 sass@1.5.1
|
|
||||||
|
|
||||||
RUN make prod -j$(($(nproc) + 1))
|
|
||||||
|
|
||||||
|
|
||||||
# templates build
|
# templates and db build
|
||||||
FROM alpine:3.7 AS templates-build
|
FROM alpine:3.13 AS templates-db-build
|
||||||
|
RUN apk add --no-cache --update bash make
|
||||||
|
|
||||||
|
ARG RELEASE=prod
|
||||||
COPY ./templates /commento/templates
|
COPY ./templates /commento/templates
|
||||||
WORKDIR /commento/templates
|
WORKDIR /commento/templates
|
||||||
|
RUN make ${RELEASE} -j$(($(nproc) + 1))
|
||||||
RUN apk update && apk add bash make
|
|
||||||
|
|
||||||
RUN make prod -j$(($(nproc) + 1))
|
|
||||||
|
|
||||||
|
|
||||||
# db build
|
|
||||||
FROM alpine:3.7 AS db-build
|
|
||||||
|
|
||||||
COPY ./db /commento/db
|
COPY ./db /commento/db
|
||||||
WORKDIR /commento/db
|
WORKDIR /commento/db
|
||||||
|
RUN make ${RELEASE} -j$(($(nproc) + 1))
|
||||||
RUN apk update && apk add bash make
|
|
||||||
|
|
||||||
RUN make prod -j$(($(nproc) + 1))
|
|
||||||
|
|
||||||
|
|
||||||
# final image
|
# final image
|
||||||
FROM alpine:3.7
|
FROM alpine:3.13
|
||||||
|
RUN apk add --no-cache --update ca-certificates
|
||||||
|
|
||||||
COPY --from=api-build /go/src/commento/api/build/prod/commento /commento/commento
|
ARG RELEASE=prod
|
||||||
COPY --from=frontend-build /commento/frontend/build/prod/*.html /commento/
|
|
||||||
COPY --from=frontend-build /commento/frontend/build/prod/css/*.css /commento/css/
|
|
||||||
COPY --from=frontend-build /commento/frontend/build/prod/js/*.js /commento/js/
|
|
||||||
COPY --from=frontend-build /commento/frontend/build/prod/images/* /commento/images/
|
|
||||||
COPY --from=frontend-build /commento/frontend/build/prod/fonts/* /commento/fonts/
|
|
||||||
COPY --from=templates-build /commento/templates/build/prod/templates/ /commento/templates/
|
|
||||||
COPY --from=db-build /commento/db/build/prod/db/ /commento/db/
|
|
||||||
|
|
||||||
RUN apk update && apk add ca-certificates --no-cache
|
COPY --from=api-build /go/src/commento/api/build/${RELEASE}/commento /commento/commento
|
||||||
|
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/js /commento/js
|
||||||
|
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/css /commento/css
|
||||||
|
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/images /commento/images
|
||||||
|
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/fonts /commento/fonts
|
||||||
|
COPY --from=frontend-build /commento/frontend/build/${RELEASE}/*.html /commento/
|
||||||
|
COPY --from=templates-db-build /commento/templates/build/${RELEASE}/templates /commento/templates/
|
||||||
|
COPY --from=templates-db-build /commento/db/build/${RELEASE}/db /commento/db/
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
WORKDIR /commento/
|
WORKDIR /commento/
|
||||||
|
|
||||||
ENV COMMENTO_BIND_ADDRESS="0.0.0.0"
|
ENV COMMENTO_BIND_ADDRESS="0.0.0.0"
|
||||||
ENTRYPOINT ["/commento/commento"]
|
ENTRYPOINT ["/commento/commento"]
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -1,90 +1,21 @@
|
|||||||
<p align="center">
|
### Commento
|
||||||
<a href="https://commento.io"><img src="https://user-images.githubusercontent.com/7521600/33375172-14b21f68-d52f-11e7-9b30-477682bccf8f.png" width=300></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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)
|
||||||
|
|
||||||
### What is Commento?
|
Commento is a platform that you can embed in your website to allow your readers to add comments. It's reasonably fast lightweight. Supports markdown, import from Disqus, voting, automated spam detection, moderation tools, sticky comments, thread locking, OAuth login, single sign-on, and email notifications.
|
||||||
|
|
||||||
Commento allows you to foster discussion on your website – if you have a blog, you can embed Commento if you want your readers to add comments. It's fast and bloat-free, has a modern interface, and is reasonably secure. Unlike most alternatives, Commento is lightweight and privacy-focused; I'll never sell your data, show ads, embed third-party tracking scripts, or inject affiliate links.
|
###### How is this different from Disqus, Facebook Comments, and the rest?
|
||||||
|
|
||||||
### Frequently Asked Questions
|
Most other products in this space do not respect your privacy; showing ads is their primary business model and that nearly always comes at the users' cost. Commento has no ads; you're the customer, not the product. While Commento is [free software](https://www.gnu.org/philosophy/free-sw.en.html), in order to keep the service sustainable, the [hosted cloud version](https://commento.io) is not offered free of cost. Commento is also orders of magnitude lighter than alternatives.
|
||||||
|
|
||||||
**I don't want to install and manage Commento on a server.**
|
###### Why should I care about my readers' privacy?
|
||||||
You can use [Commento.io](https://commento.io), the cloud version of Commento, where I do the server hosting, updates, and security and performance tuning for you. To make the hosted service self-sustainable, it is not free. You may choose the plan that best matches your financial situation and needs – all plans have all features.
|
|
||||||
|
|
||||||
**What features does Commento have?**
|
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 comes with a lot of useful features out-of-the-box: rich text support, upvotes and downvotes, automatic spam detection, moderation tools, sticky comments, thread locking, OAuth login, email notifications, and more!
|
|
||||||
|
|
||||||
**What does Commento look like? Do you have a demo?**
|
#### Installation
|
||||||
Check out [demo.commento.io](https://demo.commento.io) to play around with a live demo of Commento.
|
|
||||||
|
|
||||||
**How is Commento different from Disqus, Facebook Comments, and the rest?**
|
Read the [documentation to get started](https://docs.commento.io/installation/).
|
||||||
Most other products in this space do not respect your privacy; showing adverts is their primary business model and that nearly always comes at the users' cost. There is no free lunch. Commento is also orders of magnitude lighter than alternatives – while Disqus and Facebook take megabytes of download to load, Commento is just 11 kB.
|
|
||||||
|
|
||||||
**Is Commento free software?**
|
#### Contributing
|
||||||
Yes. Commento is made [freely available](https://gitlab.com/commento/commento) under the [MIT license](https://gitlab.com/commento/commento/blob/master/LICENSE). And it will always stay that way.
|
|
||||||
|
|
||||||
**Disqus has a free plan. Why is the [cloud version](https://commento.io) not free of cost?**
|
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).
|
||||||
When I say Commento is free, I mean [free as in freedom](https://www.gnu.org/philosophy/free-sw.en.html). The cloud version is not offered free of cost because servers cost money and offering the service for free would not be sustainable. Unlike most alternatives, Commento does not operate on adverts and shady tactics; you're the customer, not the product.
|
|
||||||
|
|
||||||
**I have nothing to hide. Why should I care about my privacy?**
|
|
||||||
The thing about privacy is that once you give up control over your information, you can't get it back. You may be fine with having your personal information sold to unknown third-parties today, but when your insurance company uses this information against you tomorrow, you'll regret it. And you'll have no recourse to correct this. Read [this Wikipedia article](https://en.wikipedia.org/wiki/Nothing_to_hide_argument) for more information.
|
|
||||||
|
|
||||||
<div><p style="margin: 0px 0px"><b>As a blog owner, why should I worry about my readers' privacy?</b><br>
|
|
||||||
Good question. For starters, your readers value their privacy. Not caring about them is disrespectful and you will end up alienating your audience; they won't come back. But even if you ignore this, you have bigger questions to answer:</p>
|
|
||||||
<ul>
|
|
||||||
<li><b>Legality</b>: Did you know that Disqus still isn't GDPR-compliant (according to their <a href="https://help.disqus.com/terms-and-policies/privacy-faq" title="At the time of writing (28 December 2018)" rel="nofollow">privacy policy</a>)?</li>
|
|
||||||
<li><b>Security</b>: What happens when a random third-party script is injected into your website?</li>
|
|
||||||
<li><b>Performance</b>: Did you know that half a second increase in page load time results in a 20% decrease in engagement and site traffic?</li>
|
|
||||||
<li><b>Ownership</b>: Who owns the content when your readers create comments?</li>
|
|
||||||
</ul></div>
|
|
||||||
|
|
||||||
**Who's behind this? Are you an evil corporation?**
|
|
||||||
My name is <a href="https://adtac.in">Adhityaa</a>, and I created the project. As someone who's still a student, I promise you I'm neither evil nor a corporation. But I'm not the only one – dozens of people have contributed to the project and Commento would not exist without these wonderful people.
|
|
||||||
|
|
||||||
**Okay, how do I get started?**
|
|
||||||
Glad you asked! You have two options – self-hosting Commento on your own server or using the [cloud version](https://commento.io). Start [from here](https://docs.commento.io/getting-started/) to decide which option is right for you and proceed from there.
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
See our [documentation on how to install Commento](https://docs.commento.io/installation/) to get started.
|
|
||||||
|
|
||||||
### Contributing
|
|
||||||
|
|
||||||
Commento is possible only because of its community. If this is your first contribution to Commento, please go through the [documentation](https://docs.commento.io/contributing/) before you begin.
|
|
||||||
|
|
||||||
Help will always be given to those who ask for it. We use IRC for chat to collaborate with other developers. You're invited to [hang out with us](https://irc.commento.io) in the `#commento-dev` channel on freenode if you want to contribute to Commento!
|
|
||||||
|
|
||||||
### Sponsors
|
|
||||||
|
|
||||||
Commento development is partially sponsored by [Mozilla](https://mozilla.org) and [DigitalOcean](https://www.digitalocean.com/) independently.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.mozilla.org/en-US/"><img src="https://user-images.githubusercontent.com/7521600/32265838-d05b2d08-bf0a-11e7-92e1-2cb183eae616.png" title="Mozilla" height="40"></a>
|
|
||||||
|
|
||||||
<a href="https://www.digitalocean.com"><img src="https://user-images.githubusercontent.com/7521600/32265839-d093c7da-bf0a-11e7-8d99-96a940041d06.png" title="DigitalOcean" height="40"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### License
|
|
||||||
|
|
||||||
```
|
|
||||||
Copyright 2018 Commento, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
```
|
|
||||||
|
|||||||
170
api/Gopkg.lock
generated
170
api/Gopkg.lock
generated
@@ -1,170 +0,0 @@
|
|||||||
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
|
|
||||||
|
|
||||||
|
|
||||||
[[projects]]
|
|
||||||
digest = "1:5c3894b2aa4d6bead0ceeea6831b305d62879c871780e7b76296ded1b004bc57"
|
|
||||||
name = "cloud.google.com/go"
|
|
||||||
packages = ["compute/metadata"]
|
|
||||||
pruneopts = "UT"
|
|
||||||
revision = "64a2037ec6be8a4b0c1d1f706ed35b428b989239"
|
|
||||||
version = "v0.26.0"
|
|
||||||
|
|
||||||
[[projects]]
|
|
||||||
branch = "master"
|
|
||||||
digest = "1:9769b231d8f5ff406a012aa7f293e45ed69d11617832a1c3c7b8c6ce1558a2a1"
|
|
||||||
name = "github.com/adtac/go-akismet"
|
|
||||||
packages = ["akismet"]
|
|
||||||
pruneopts = "UT"
|
|
||||||
revision = "0ca9e1023047c869ecd4bd3c20780511597a4a77"
|
|
||||||
|
|
||||||
[[projects]]
|
|
||||||
digest = "1:15042ad3498153684d09f393bbaec6b216c8eec6d61f63dff711de7d64ed8861"
|
|
||||||
name = "github.com/golang/protobuf"
|
|
||||||
packages = ["proto"]
|
|
||||||
pruneopts = "UT"
|
|
||||||
revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
|
|
||||||
version = "v1.1.0"
|
|
||||||
|
|
||||||
[[projects]]
|
|
||||||
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:82e6e4dc5ab71680d89684e4649be630fdeeaf81feb8e88e4a56273a0cd4d966"
|
|
||||||
name = "golang.org/x/oauth2"
|
|
||||||
packages = [
|
|
||||||
".",
|
|
||||||
"github",
|
|
||||||
"google",
|
|
||||||
"internal",
|
|
||||||
"jws",
|
|
||||||
"jwt",
|
|
||||||
]
|
|
||||||
pruneopts = "UT"
|
|
||||||
revision = "3d292e4d0cdc3a0113e6d207bb137145ef1de42f"
|
|
||||||
|
|
||||||
[[projects]]
|
|
||||||
digest = "1:c8907869850adaa8bd7631887948d0684f3787d0912f1c01ab72581a6c34432e"
|
|
||||||
name = "google.golang.org/appengine"
|
|
||||||
packages = [
|
|
||||||
".",
|
|
||||||
"internal",
|
|
||||||
"internal/app_identity",
|
|
||||||
"internal/base",
|
|
||||||
"internal/datastore",
|
|
||||||
"internal/log",
|
|
||||||
"internal/modules",
|
|
||||||
"internal/remote_api",
|
|
||||||
"internal/urlfetch",
|
|
||||||
"urlfetch",
|
|
||||||
]
|
|
||||||
pruneopts = "UT"
|
|
||||||
revision = "b1f26356af11148e710935ed1ac8a7f5702c7612"
|
|
||||||
version = "v1.1.0"
|
|
||||||
|
|
||||||
[solve-meta]
|
|
||||||
analyzer-name = "dep"
|
|
||||||
analyzer-version = 1
|
|
||||||
input-imports = [
|
|
||||||
"github.com/adtac/go-akismet/akismet",
|
|
||||||
"github.com/gorilla/handlers",
|
|
||||||
"github.com/gorilla/mux",
|
|
||||||
"github.com/lib/pq",
|
|
||||||
"github.com/lunny/html2md",
|
|
||||||
"github.com/microcosm-cc/bluemonday",
|
|
||||||
"github.com/op/go-logging",
|
|
||||||
"github.com/russross/blackfriday",
|
|
||||||
"golang.org/x/crypto/bcrypt",
|
|
||||||
"golang.org/x/net/html",
|
|
||||||
"golang.org/x/oauth2",
|
|
||||||
"golang.org/x/oauth2/github",
|
|
||||||
"golang.org/x/oauth2/google",
|
|
||||||
]
|
|
||||||
solver-name = "gps-cdcl"
|
|
||||||
solver-version = 1
|
|
||||||
@@ -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
|
|
||||||
10
api/Makefile
10
api/Makefile
@@ -25,15 +25,15 @@ clean:
|
|||||||
# later down the line).
|
# later down the line).
|
||||||
|
|
||||||
devel-go:
|
devel-go:
|
||||||
dep ensure
|
GO111MODULE=on go mod vendor
|
||||||
go build -i -v -o $(GO_DEVEL_BUILD_BINARY)
|
GO111MODULE=on go build -mod=vendor -v -o $(GO_DEVEL_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)"
|
||||||
|
|
||||||
prod-go:
|
prod-go:
|
||||||
dep ensure
|
GO111MODULE=on go mod vendor
|
||||||
go build -i -v -o $(GO_PROD_BUILD_BINARY)
|
GO111MODULE=on go build -mod=vendor -v -o $(GO_PROD_BUILD_BINARY) -ldflags "-X main.version=$(shell git describe --tags)"
|
||||||
|
|
||||||
test-go:
|
test-go:
|
||||||
dep ensure
|
GO111MODULE=on go mod vendor
|
||||||
go test -v .
|
go test -v .
|
||||||
|
|
||||||
$(shell mkdir -p $(GO_DEVEL_BUILD_DIR) $(GO_PROD_BUILD_DIR))
|
$(shell mkdir -p $(GO_DEVEL_BUILD_DIR) $(GO_PROD_BUILD_DIR))
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ type comment struct {
|
|||||||
Html string `json:"html"`
|
Html string `json:"html"`
|
||||||
ParentHex string `json:"parentHex"`
|
ParentHex string `json:"parentHex"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
State string `json:"state"`
|
State string `json:"state,omitempty"`
|
||||||
CreationDate time.Time `json:"creationDate"`
|
CreationDate time.Time `json:"creationDate"`
|
||||||
Direction int `json:"direction"`
|
Direction int `json:"direction"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/lib/pq"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func commentCount(domain string, path string) (int, error) {
|
func commentCount(domain string, paths []string) (map[string]int, error) {
|
||||||
// path can be empty
|
commentCounts := map[string]int{}
|
||||||
|
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
return 0, errorMissingField
|
return nil, errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := pageGet(domain, path)
|
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 {
|
if err != nil {
|
||||||
return 0, errorInternal
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.CommentCount, nil
|
commentCounts[path] = commentCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return commentCounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func commentCountHandler(w http.ResponseWriter, r *http.Request) {
|
func commentCountHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
type request struct {
|
type request struct {
|
||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
Path *string `json:"path"`
|
Paths *[]string `json:"paths"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var x request
|
var x request
|
||||||
@@ -31,13 +55,12 @@ func commentCountHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
domain := domainStrip(*x.Domain)
|
domain := domainStrip(*x.Domain)
|
||||||
path := *x.Path
|
|
||||||
|
|
||||||
count, err := commentCount(domain, path)
|
commentCounts, err := commentCount(domain, *x.Paths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "count": count})
|
bodyMarshal(w, response{"success": true, "commentCounts": commentCounts})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ func TestCommentCountBasics(t *testing.T) {
|
|||||||
commentNew(commenterHex, "example.com", "/path.html", "root", "**bar**", "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())
|
commentNew(commenterHex, "example.com", "/path.html", "root", "**baz**", "unapproved", time.Now().UTC())
|
||||||
|
|
||||||
count, err := commentCount("example.com", "/path.html")
|
counts, err := commentCount("example.com", []string{"/path.html"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("unexpected error counting comments: %v", err)
|
t.Errorf("unexpected error counting comments: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count != 2 {
|
if counts["/path.html"] != 3 {
|
||||||
t.Errorf("expected count=2 got count=%d", count)
|
t.Errorf("expected count=3 got count=%d", counts["/path.html"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,25 +29,25 @@ func TestCommentCountBasics(t *testing.T) {
|
|||||||
func TestCommentCountNewPage(t *testing.T) {
|
func TestCommentCountNewPage(t *testing.T) {
|
||||||
failTestOnError(t, setupTestEnv())
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
count, err := commentCount("example.com", "/path.html")
|
counts, err := commentCount("example.com", []string{"/path.html"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("unexpected error counting comments: %v", err)
|
t.Errorf("unexpected error counting comments: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count != 0 {
|
if counts["/path.html"] != 0 {
|
||||||
t.Errorf("expected count=0 got count=%d", count)
|
t.Errorf("expected count=0 got count=%d", counts["/path.html"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommentCountEmpty(t *testing.T) {
|
func TestCommentCountEmpty(t *testing.T) {
|
||||||
if _, err := commentCount("example.com", ""); err != nil {
|
if _, err := commentCount("example.com", []string{""}); err != nil {
|
||||||
t.Errorf("unexpected error counting comments on empty path: %v", err)
|
t.Errorf("unexpected error counting comments on empty path: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := commentCount("", ""); err == nil {
|
if _, err := commentCount("", []string{""}); err == nil {
|
||||||
t.Errorf("expected error not found counting comments with empty everything")
|
t.Errorf("expected error not found counting comments with empty everything")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,26 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func commentDelete(commentHex string) error {
|
func commentDelete(commentHex string, deleterHex string) error {
|
||||||
if commentHex == "" {
|
if commentHex == "" || deleterHex == "" {
|
||||||
return errorMissingField
|
return errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
DELETE FROM comments
|
UPDATE comments
|
||||||
|
SET
|
||||||
|
deleted = true,
|
||||||
|
markdown = '[deleted]',
|
||||||
|
html = '[deleted]',
|
||||||
|
commenterHex = 'anonymous',
|
||||||
|
deleterHex = $2,
|
||||||
|
deletionDate = $3
|
||||||
WHERE commentHex = $1;
|
WHERE commentHex = $1;
|
||||||
`
|
`
|
||||||
_, err := db.Exec(statement, commentHex)
|
_, err := db.Exec(statement, commentHex, deleterHex, time.Now().UTC())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: make sure this is the error is actually non-existant commentHex
|
// TODO: make sure this is the error is actually non-existant commentHex
|
||||||
@@ -41,6 +49,12 @@ func commentDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cm, err := commentGetByCommentHex(*x.CommentHex)
|
||||||
|
if err != nil {
|
||||||
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
domain, _, err := commentDomainPathGet(*x.CommentHex)
|
domain, _, err := commentDomainPathGet(*x.CommentHex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
@@ -53,12 +67,12 @@ func commentDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isModerator {
|
if !isModerator && cm.CommenterHex != c.CommenterHex {
|
||||||
bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()})
|
bodyMarshal(w, response{"success": false, "message": errorNotModerator.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = commentDelete(*x.CommentHex); err != nil {
|
if err = commentDelete(*x.CommentHex, c.CommenterHex); err != nil {
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ import (
|
|||||||
func TestCommentDeleteBasics(t *testing.T) {
|
func TestCommentDeleteBasics(t *testing.T) {
|
||||||
failTestOnError(t, setupTestEnv())
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
commentHex, _ := commentNew("temp-commenter-hex", "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
commenterHex := "temp-commenter-hex"
|
||||||
commentNew("temp-commenter-hex", "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC())
|
commentHex, _ := commentNew(commenterHex, "example.com", "/path.html", "root", "**foo**", "approved", time.Now().UTC())
|
||||||
|
commentNew(commenterHex, "example.com", "/path.html", commentHex, "**bar**", "approved", time.Now().UTC())
|
||||||
|
|
||||||
if err := commentDelete(commentHex); err != nil {
|
if err := commentDelete(commentHex, commenterHex); err != nil {
|
||||||
t.Errorf("unexpected error deleting comment: %v", err)
|
t.Errorf("unexpected error deleting comment: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c, _, _ := commentList("temp-commenter-hex", "example.com", "/path.html", false)
|
c, _, _ := commentList(commenterHex, "example.com", "/path.html", false)
|
||||||
|
|
||||||
if len(c) != 0 {
|
if len(c) != 0 {
|
||||||
t.Errorf("expected no comments found %d comments", len(c))
|
t.Errorf("expected no comments found %d comments", len(c))
|
||||||
@@ -27,7 +28,7 @@ func TestCommentDeleteBasics(t *testing.T) {
|
|||||||
func TestCommentDeleteEmpty(t *testing.T) {
|
func TestCommentDeleteEmpty(t *testing.T) {
|
||||||
failTestOnError(t, setupTestEnv())
|
failTestOnError(t, setupTestEnv())
|
||||||
|
|
||||||
if err := commentDelete(""); err == nil {
|
if err := commentDelete("", "test-commenter-hex"); err == nil {
|
||||||
t.Errorf("expected error deleting comment with empty commentHex")
|
t.Errorf("expected error deleting comment with empty commentHex")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -12,7 +12,16 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT commentHex, commenterHex, markdown, html, parentHex, score, state, creationDate
|
SELECT
|
||||||
|
commentHex,
|
||||||
|
commenterHex,
|
||||||
|
markdown,
|
||||||
|
html,
|
||||||
|
parentHex,
|
||||||
|
score,
|
||||||
|
state,
|
||||||
|
deleted,
|
||||||
|
creationDate
|
||||||
FROM comments
|
FROM comments
|
||||||
WHERE
|
WHERE
|
||||||
comments.domain = $1 AND
|
comments.domain = $1 AND
|
||||||
@@ -21,13 +30,9 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
|||||||
|
|
||||||
if !includeUnapproved {
|
if !includeUnapproved {
|
||||||
if commenterHex == "anonymous" {
|
if commenterHex == "anonymous" {
|
||||||
statement += `
|
statement += `AND state = 'approved'`
|
||||||
AND state = 'approved'
|
|
||||||
`
|
|
||||||
} else {
|
} else {
|
||||||
statement += `
|
statement += `AND (state = 'approved' OR commenterHex = $3)`
|
||||||
AND (state = 'approved' OR commenterHex = $3)
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +59,16 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
|||||||
comments := []comment{}
|
comments := []comment{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
c := comment{}
|
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
|
return nil, nil, errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +86,10 @@ func commentList(commenterHex string, domain string, path string, includeUnappro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if commenterHex != c.CommenterHex {
|
||||||
|
c.Markdown = ""
|
||||||
|
}
|
||||||
|
|
||||||
if !includeUnapproved {
|
if !includeUnapproved {
|
||||||
c.State = ""
|
c.State = ""
|
||||||
}
|
}
|
||||||
@@ -120,6 +138,8 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
commenterHex := "anonymous"
|
commenterHex := "anonymous"
|
||||||
isModerator := false
|
isModerator := false
|
||||||
|
modList := map[string]bool{}
|
||||||
|
|
||||||
if *x.CommenterToken != "anonymous" {
|
if *x.CommenterToken != "anonymous" {
|
||||||
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -134,11 +154,15 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, mod := range d.Moderators {
|
for _, mod := range d.Moderators {
|
||||||
|
modList[mod.Email] = true
|
||||||
if mod.Email == c.Email {
|
if mod.Email == c.Email {
|
||||||
isModerator = true
|
isModerator = true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
for _, mod := range d.Moderators {
|
||||||
|
modList[mod.Email] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
domainViewRecord(domain, commenterHex)
|
domainViewRecord(domain, commenterHex)
|
||||||
@@ -149,16 +173,33 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
bodyMarshal(w, response{
|
||||||
"success": true,
|
"success": true,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
"commenters": commenters,
|
"commenters": _commenters,
|
||||||
"requireModeration": d.RequireModeration,
|
"requireModeration": d.RequireModeration,
|
||||||
"requireIdentification": d.RequireIdentification,
|
"requireIdentification": d.RequireIdentification,
|
||||||
"isFrozen": d.State == "frozen",
|
"isFrozen": d.State == "frozen",
|
||||||
"isModerator": isModerator,
|
"isModerator": isModerator,
|
||||||
|
"defaultSortPolicy": d.DefaultSortPolicy,
|
||||||
"attributes": p,
|
"attributes": p,
|
||||||
"configuredOauths": configuredOauths,
|
"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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin
|
|||||||
|
|
||||||
html := markdownToHtml(markdown)
|
html := markdownToHtml(markdown)
|
||||||
|
|
||||||
|
if err = pageNew(domain, path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
comments (commentHex, domain, path, commenterHex, parentHex, markdown, html, creationDate, state)
|
comments (commentHex, domain, path, commenterHex, parentHex, markdown, html, creationDate, state)
|
||||||
@@ -41,10 +45,6 @@ func commentNew(commenterHex string, domain string, path string, parentHex strin
|
|||||||
return "", errorInternal
|
return "", errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = pageNew(domain, path); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return commentHex, nil
|
return commentHex, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,61 +82,37 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// logic: (empty column indicates the value doesn't matter)
|
var commenterHex, commenterName, commenterEmail, commenterLink string
|
||||||
// | anonymous | moderator | requireIdentification | requireModeration | moderateAllAnonymous | approved? |
|
var isModerator bool
|
||||||
// |-----------+-----------+-----------------------+-------------------+----------------------+-----------|
|
|
||||||
// | 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" {
|
if *x.CommenterToken == "anonymous" {
|
||||||
commenterHex = "anonymous"
|
commenterHex, commenterName, commenterEmail, commenterLink = "anonymous", "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 {
|
} else {
|
||||||
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
c, err := commenterGetByCommenterToken(*x.CommenterToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
commenterHex, commenterName, commenterEmail, commenterLink = c.CommenterHex, c.Name, c.Email, c.Link
|
||||||
// cheaper than a SQL query as we already have this information
|
|
||||||
isModerator := false
|
|
||||||
for _, mod := range d.Moderators {
|
for _, mod := range d.Moderators {
|
||||||
if mod.Email == c.Email {
|
if mod.Email == c.Email {
|
||||||
isModerator = true
|
isModerator = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
commenterHex = c.CommenterHex
|
var state string
|
||||||
|
|
||||||
if isModerator {
|
if isModerator {
|
||||||
state = "approved"
|
state = "approved"
|
||||||
} else {
|
} else if d.RequireModeration {
|
||||||
if isSpam(*x.Domain, getIp(r), getUserAgent(r), c.Name, c.Email, c.Link, *x.Markdown) {
|
state = "unapproved"
|
||||||
|
} else if commenterHex == "anonymous" && d.ModerateAllAnonymous {
|
||||||
|
state = "unapproved"
|
||||||
|
} else if d.AutoSpamFilter && isSpam(*x.Domain, getIp(r), getUserAgent(r), commenterName, commenterEmail, commenterLink, *x.Markdown) {
|
||||||
state = "flagged"
|
state = "flagged"
|
||||||
} else {
|
|
||||||
if d.RequireModeration {
|
|
||||||
state = "unapproved"
|
|
||||||
} else {
|
} else {
|
||||||
state = "approved"
|
state = "approved"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commentHex, err := commentNew(commenterHex, domain, path, *x.ParentHex, *x.Markdown, state, time.Now().UTC())
|
commentHex, err := commentNew(commenterHex, domain, path, *x.ParentHex, *x.Markdown, state, time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -149,6 +125,6 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": html})
|
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": html})
|
||||||
if smtpConfigured {
|
if smtpConfigured {
|
||||||
go emailNotificationNew(d, path, commenterHex, commentHex, *x.ParentHex, state)
|
go emailNotificationNew(d, path, commenterHex, commentHex, html, *x.ParentHex, state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func commentVote(commenterHex string, commentHex string, direction int) error {
|
|||||||
|
|
||||||
var authorHex string
|
var authorHex string
|
||||||
if err := row.Scan(&authorHex); err != nil {
|
if err := row.Scan(&authorHex); err != nil {
|
||||||
logger.Errorf("erorr selecting authorHex for vote")
|
logger.Errorf("error selecting authorHex for vote")
|
||||||
return errorInternal
|
return errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ type commenter struct {
|
|||||||
Photo string `json:"photo"`
|
Photo string `json:"photo"`
|
||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
JoinDate time.Time `json:"joinDate,omitempty"`
|
JoinDate time.Time `json:"joinDate,omitempty"`
|
||||||
|
IsModerator bool `json:"isModerator"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,42 @@ package main
|
|||||||
|
|
||||||
import ()
|
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) {
|
func commenterGetByHex(commenterHex string) (commenter, error) {
|
||||||
if commenterHex == "" {
|
if commenterHex == "" {
|
||||||
return commenter{}, errorMissingField
|
return commenter{}, errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT commenterHex, email, name, link, photo, provider, joinDate
|
SELECT ` + commentersRowColumns + `
|
||||||
FROM commenters
|
FROM commenters
|
||||||
WHERE commenterHex = $1;
|
WHERE commenterHex = $1;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, commenterHex)
|
row := db.QueryRow(statement, commenterHex)
|
||||||
|
|
||||||
c := commenter{}
|
var c commenter
|
||||||
if err := row.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil {
|
if err := commentersRowScan(row, &c); err != nil {
|
||||||
// TODO: is this the only error?
|
// TODO: is this the only error?
|
||||||
return commenter{}, errorNoSuchCommenter
|
return commenter{}, errorNoSuchCommenter
|
||||||
}
|
}
|
||||||
@@ -29,14 +51,14 @@ func commenterGetByEmail(provider string, email string) (commenter, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT commenterHex, email, name, link, photo, provider, joinDate
|
SELECT ` + commentersRowColumns + `
|
||||||
FROM commenters
|
FROM commenters
|
||||||
WHERE email = $1 AND provider = $2;
|
WHERE email = $1 AND provider = $2;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, email, provider)
|
row := db.QueryRow(statement, email, provider)
|
||||||
|
|
||||||
c := commenter{}
|
var c commenter
|
||||||
if err := row.Scan(&c.CommenterHex, &c.Email, &c.Name, &c.Link, &c.Photo, &c.Provider, &c.JoinDate); err != nil {
|
if err := commentersRowScan(row, &c); err != nil {
|
||||||
// TODO: is this the only error?
|
// TODO: is this the only error?
|
||||||
return commenter{}, errorNoSuchCommenter
|
return commenter{}, errorNoSuchCommenter
|
||||||
}
|
}
|
||||||
@@ -50,22 +72,22 @@ func commenterGetByCommenterToken(commenterToken string) (commenter, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT commenterHex
|
SELECT ` + commentersRowColumns + `
|
||||||
FROM commenterSessions
|
FROM commenterSessions
|
||||||
|
JOIN commenters ON commenterSessions.commenterHex = commenters.commenterHex
|
||||||
WHERE commenterToken = $1;
|
WHERE commenterToken = $1;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, commenterToken)
|
row := db.QueryRow(statement, commenterToken)
|
||||||
|
|
||||||
var commenterHex string
|
var c commenter
|
||||||
if err := row.Scan(&commenterHex); err != nil {
|
if err := commentersRowScan(row, &c); err != nil {
|
||||||
// TODO: is the only error?
|
// TODO: is this the only error?
|
||||||
return commenter{}, errorNoSuchToken
|
return commenter{}, errorNoSuchToken
|
||||||
}
|
}
|
||||||
|
|
||||||
if commenterHex == "none" {
|
if c.CommenterHex == "none" {
|
||||||
return commenter{}, errorNoSuchToken
|
return commenter{}, errorNoSuchToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use a join instead of two queries?
|
return c, nil
|
||||||
return commenterGetByHex(commenterHex)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,5 +74,11 @@ func commenterLoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken, "commenter": c})
|
e, err := emailGet(c.Email)
|
||||||
|
if err != nil {
|
||||||
|
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyMarshal(w, response{"success": true, "commenterToken": commenterToken, "commenter": c, "email": e})
|
||||||
}
|
}
|
||||||
|
|||||||
59
api/commenter_photo.go
Normal file
59
api/commenter_photo.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 == "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
|
||||||
|
}
|
||||||
|
}
|
||||||
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})
|
||||||
|
}
|
||||||
@@ -50,6 +50,19 @@ func configParse() error {
|
|||||||
|
|
||||||
"GITHUB_KEY": "",
|
"GITHUB_KEY": "",
|
||||||
"GITHUB_SECRET": "",
|
"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 {
|
for key, value := range defaults {
|
||||||
@@ -60,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
|
// Mandatory config parameters
|
||||||
for _, env := range []string{"POSTGRES", "PORT", "ORIGIN", "FORBID_NEW_OWNERS", "MAX_IDLE_PG_CONNECTIONS"} {
|
for _, env := range []string{"POSTGRES", "PORT", "ORIGIN", "FORBID_NEW_OWNERS", "MAX_IDLE_PG_CONNECTIONS"} {
|
||||||
if os.Getenv(env) == "" {
|
if os.Getenv(env) == "" {
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ func configFileLoad(filepath string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv(key[9:]) != "" {
|
if os.Getenv(key) != "" {
|
||||||
|
// Config files have lower precedence.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Setenv(key[9:], value)
|
os.Setenv(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -37,19 +37,19 @@ func TestConfigFileLoadBasics(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Setenv("PORT", "9000")
|
os.Setenv("COMMENTO_PORT", "9000")
|
||||||
if err := configFileLoad(f.Name()); err != nil {
|
if err := configFileLoad(f.Name()); err != nil {
|
||||||
t.Errorf("unexpected error loading config file: %v", err)
|
t.Errorf("unexpected error loading config file: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv("PORT") != "9000" {
|
if os.Getenv("COMMENTO_PORT") != "9000" {
|
||||||
t.Errorf("expected PORT=9000 got PORT=%s", os.Getenv("PORT"))
|
t.Errorf("expected COMMENTO_PORT=9000 got COMMENTO_PORT=%s", os.Getenv("COMMENTO_PORT"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv("GZIP_STATIC") != "true" {
|
if os.Getenv("COMMENTO_GZIP_STATIC") != "true" {
|
||||||
t.Errorf("expected GZIP_STATIC=true got GZIP_STATIC=%s", os.Getenv("GZIP_STATIC"))
|
t.Errorf("expected COMMENTO_GZIP_STATIC=true got COMMENTO_GZIP_STATIC=%s", os.Getenv("COMMENTO_GZIP_STATIC"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func TestConfigParseBasics(t *testing.T) {
|
|||||||
|
|
||||||
os.Setenv("COMMENTO_BIND_ADDRESS", "192.168.1.100")
|
os.Setenv("COMMENTO_BIND_ADDRESS", "192.168.1.100")
|
||||||
|
|
||||||
|
os.Setenv("COMMENTO_PORT", "")
|
||||||
if err := configParse(); err != nil {
|
if err := configParse(); err != nil {
|
||||||
t.Errorf("unexpected error when parsing config: %v", err)
|
t.Errorf("unexpected error when parsing config: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
var version = "v1.6.2"
|
var version string
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func emailNotificationBegin() error {
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
statement := `
|
|
||||||
SELECT email, sendModeratorNotifications, sendReplyNotifications
|
|
||||||
FROM emails
|
|
||||||
WHERE pendingEmails > 0 AND lastEmailNotificationDate < $1;
|
|
||||||
`
|
|
||||||
rows, err := db.Query(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute))
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot query domains: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var email string
|
|
||||||
var sendModeratorNotifications bool
|
|
||||||
var sendReplyNotifications bool
|
|
||||||
if err = rows.Scan(&email, &sendModeratorNotifications, &sendReplyNotifications); err != nil {
|
|
||||||
logger.Errorf("cannot scan email in cron job to send notifications: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := emailQueue[email]; !ok {
|
|
||||||
if err = emailNotificationPendingReset(email); err != nil {
|
|
||||||
logger.Errorf("error resetting pendingEmails: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cont := true
|
|
||||||
kindListMap := map[string][]emailNotification{}
|
|
||||||
for cont {
|
|
||||||
select {
|
|
||||||
case e := <-emailQueue[email]:
|
|
||||||
if _, ok := kindListMap[e.Kind]; !ok {
|
|
||||||
kindListMap[e.Kind] = []emailNotification{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.Kind == "reply" && sendReplyNotifications) || sendModeratorNotifications {
|
|
||||||
kindListMap[e.Kind] = append(kindListMap[e.Kind], e)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
cont = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for kind, list := range kindListMap {
|
|
||||||
go emailNotificationSend(email, kind, list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Minute)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,9 +11,14 @@ import (
|
|||||||
|
|
||||||
func dbConnect(retriesLeft int) error {
|
func dbConnect(retriesLeft int) error {
|
||||||
con := os.Getenv("POSTGRES")
|
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)
|
db, err = sql.Open("postgres", con)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("cannot open connection to postgres: %v", err)
|
logger.Errorf("cannot open connection to postgres: %v", err)
|
||||||
|
|||||||
@@ -17,4 +17,13 @@ type domain struct {
|
|||||||
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
|
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
|
||||||
Moderators []moderator `json:"moderators"`
|
Moderators []moderator `json:"moderators"`
|
||||||
EmailNotificationPolicy string `json:"emailNotificationPolicy"`
|
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 := `
|
statement := `
|
||||||
DELETE FROM
|
DELETE FROM domains
|
||||||
domains
|
|
||||||
WHERE domain = $1;
|
WHERE domain = $1;
|
||||||
`
|
`
|
||||||
_, err := db.Exec(statement, domain)
|
_, err := db.Exec(statement, domain)
|
||||||
@@ -19,24 +18,13 @@ func domainDelete(domain string) error {
|
|||||||
return errorNoSuchDomain
|
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 = `
|
statement = `
|
||||||
DELETE FROM views
|
DELETE FROM views
|
||||||
WHERE views.domain = $1;
|
WHERE views.domain = $1;
|
||||||
`
|
`
|
||||||
_, err = db.Exec(statement, domain)
|
_, err = db.Exec(statement, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("cannot delete views: %v", err)
|
logger.Errorf("cannot delete domain from views: %v", err)
|
||||||
return errorInternal
|
return errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,27 +34,23 @@ func domainDelete(domain string) error {
|
|||||||
`
|
`
|
||||||
_, err = db.Exec(statement, domain)
|
_, err = db.Exec(statement, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("cannot delete domain moderators: %v", err)
|
logger.Errorf("cannot delete domain from moderators: %v", err)
|
||||||
return errorInternal
|
return errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
statement = `
|
statement = `
|
||||||
DELETE FROM comments
|
DELETE FROM ssotokens
|
||||||
WHERE comments.domain = $1;
|
WHERE ssotokens.domain = $1;
|
||||||
`
|
`
|
||||||
_, err = db.Exec(statement, domain)
|
_, err = db.Exec(statement, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(statement, domain)
|
logger.Errorf("cannot delete domain from ssotokens: %v", err)
|
||||||
return errorInternal
|
return errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
statement = `
|
// comments, votes, and pages are handled by domainClear
|
||||||
DELETE FROM pages
|
if err = domainClear(domain); err != nil {
|
||||||
WHERE pages.domain = $1;
|
logger.Errorf("cannot clear domain: %v", err)
|
||||||
`
|
|
||||||
_, err = db.Exec(statement, domain)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf(statement, domain)
|
|
||||||
return errorInternal
|
return errorInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,7 @@ func domainExportBeginError(email string, toName string, domain string, err erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func domainExportBegin(email string, toName string, domain string) {
|
func domainExportBegin(email string, toName string, domain string) {
|
||||||
type dataExport struct {
|
e := commentoExportV1{Version: 1, Comments: []comment{}, Commenters: []commenter{}}
|
||||||
Version int `json:"version"`
|
|
||||||
Comments []comment `json:"comments"`
|
|
||||||
Commenters []commenter `json:"commenters"`
|
|
||||||
}
|
|
||||||
|
|
||||||
e := dataExport{Version: 1, Comments: []comment{}, Commenters: []commenter{}}
|
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT commentHex, domain, path, commenterHex, markdown, parentHex, score, state, creationDate
|
SELECT commentHex, domain, path, commenterHex, markdown, parentHex, score, state, creationDate
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ func domainExportDownloadHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprintf(w, "Error: that exportHex does not exist\n")
|
fmt.Fprintf(w, "Error: that exportHex does not exist\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s-%v.gz"`, domain, creationDate.Unix()))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s-%v.json.gz"`, domain, creationDate.Unix()))
|
||||||
w.Header().Set("Content-Encoding", "gzip")
|
|
||||||
w.Write(binData)
|
w.Write(binData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,61 @@ package main
|
|||||||
|
|
||||||
import ()
|
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) {
|
func domainGet(dmn string) (domain, error) {
|
||||||
if dmn == "" {
|
if dmn == "" {
|
||||||
return domain{}, errorMissingField
|
return domain{}, errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous, emailNotificationPolicy
|
SELECT ` + domainsRowColumns + `
|
||||||
FROM domains
|
FROM domains
|
||||||
WHERE domain = $1;
|
WHERE domain = $1;
|
||||||
`
|
`
|
||||||
@@ -16,7 +64,7 @@ func domainGet(dmn string) (domain, error) {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
d := domain{}
|
d := domain{}
|
||||||
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous, &d.EmailNotificationPolicy); err != nil {
|
if err = domainsRowScan(row, &d); err != nil {
|
||||||
return d, errorNoSuchDomain
|
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
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ func domainList(ownerHex string) ([]domain, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous, emailNotificationPolicy
|
SELECT ` + domainsRowColumns + `
|
||||||
FROM domains
|
FROM domains
|
||||||
WHERE ownerHex=$1;
|
WHERE ownerHex=$1;
|
||||||
`
|
`
|
||||||
@@ -23,8 +23,8 @@ func domainList(ownerHex string) ([]domain, error) {
|
|||||||
|
|
||||||
domains := []domain{}
|
domains := []domain{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
d := domain{}
|
var d domain
|
||||||
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous, &d.EmailNotificationPolicy); err != nil {
|
if err = domainsRowScan(rows, &d); err != nil {
|
||||||
logger.Errorf("cannot Scan domain: %v", err)
|
logger.Errorf("cannot Scan domain: %v", err)
|
||||||
return nil, errorInternal
|
return nil, errorInternal
|
||||||
}
|
}
|
||||||
@@ -63,5 +63,14 @@ func domainListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@ func domainNew(ownerHex string, name string, domain string) error {
|
|||||||
return errorMissingField
|
return errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(domain, "/") {
|
||||||
|
return errorInvalidDomain
|
||||||
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
domains (ownerHex, name, domain, creationDate)
|
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 {
|
func domainUpdate(d domain) error {
|
||||||
|
if d.SsoProvider && d.SsoUrl == "" {
|
||||||
|
return errorMissingField
|
||||||
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
UPDATE domains
|
UPDATE domains
|
||||||
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6, moderateAllAnonymous=$7, emailNotificationPolicy=$8
|
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;
|
WHERE domain=$1;
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification, d.ModerateAllAnonymous, d.EmailNotificationPolicy)
|
_, 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 {
|
if err != nil {
|
||||||
logger.Errorf("cannot update non-moderators: %v", err)
|
logger.Errorf("cannot update non-moderators: %v", err)
|
||||||
return errorInternal
|
return errorInternal
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ type email struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
UnsubscribeSecretHex string `json:"unsubscribeSecretHex"`
|
UnsubscribeSecretHex string `json:"unsubscribeSecretHex"`
|
||||||
LastEmailNotificationDate time.Time `json:"lastEmailNotificationDate"`
|
LastEmailNotificationDate time.Time `json:"lastEmailNotificationDate"`
|
||||||
PendingEmails int `json:"-"`
|
|
||||||
SendReplyNotifications bool `json:"sendReplyNotifications"`
|
SendReplyNotifications bool `json:"sendReplyNotifications"`
|
||||||
SendModeratorNotifications bool `json:"sendModeratorNotifications"`
|
SendModeratorNotifications bool `json:"sendModeratorNotifications"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,34 @@ import (
|
|||||||
"net/http"
|
"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) {
|
func emailGet(em string) (email, error) {
|
||||||
statement := `
|
statement := `
|
||||||
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
|
SELECT ` + emailsRowColumns + `
|
||||||
FROM emails
|
FROM emails
|
||||||
WHERE email = $1;
|
WHERE email = $1;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, em)
|
row := db.QueryRow(statement, em)
|
||||||
|
|
||||||
e := email{}
|
var e email
|
||||||
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); err != nil {
|
if err := emailsRowScan(row, &e); err != nil {
|
||||||
// TODO: is this the only error?
|
// TODO: is this the only error?
|
||||||
return e, errorNoSuchEmail
|
return e, errorNoSuchEmail
|
||||||
}
|
}
|
||||||
@@ -23,14 +41,14 @@ func emailGet(em string) (email, error) {
|
|||||||
|
|
||||||
func emailGetByUnsubscribeSecretHex(unsubscribeSecretHex string) (email, error) {
|
func emailGetByUnsubscribeSecretHex(unsubscribeSecretHex string) (email, error) {
|
||||||
statement := `
|
statement := `
|
||||||
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
|
SELECT ` + emailsRowColumns + `
|
||||||
FROM emails
|
FROM emails
|
||||||
WHERE unsubscribeSecretHex = $1;
|
WHERE unsubscribeSecretHex = $1;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, unsubscribeSecretHex)
|
row := db.QueryRow(statement, unsubscribeSecretHex)
|
||||||
|
|
||||||
e := email{}
|
e := email{}
|
||||||
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); err != nil {
|
if err := emailsRowScan(row, &e); err != nil {
|
||||||
// TODO: is this the only error?
|
// TODO: is this the only error?
|
||||||
return e, errorNoSuchUnsubscribeSecretHex
|
return e, errorNoSuchUnsubscribeSecretHex
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,7 @@ import (
|
|||||||
|
|
||||||
func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
|
func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
unsubscribeSecretHex := r.FormValue("unsubscribeSecretHex")
|
unsubscribeSecretHex := r.FormValue("unsubscribeSecretHex")
|
||||||
e, err := emailGetByUnsubscribeSecretHex(unsubscribeSecretHex)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(w, "error: %v", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
action := r.FormValue("action")
|
action := r.FormValue("action")
|
||||||
if action != "delete" && action != "approve" {
|
|
||||||
fmt.Fprintf(w, "error: invalid action")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
commentHex := r.FormValue("commentHex")
|
commentHex := r.FormValue("commentHex")
|
||||||
if commentHex == "" {
|
if commentHex == "" {
|
||||||
fmt.Fprintf(w, "error: invalid commentHex")
|
fmt.Fprintf(w, "error: invalid commentHex")
|
||||||
@@ -26,23 +15,35 @@ func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT domain
|
SELECT domain, deleted
|
||||||
FROM comments
|
FROM comments
|
||||||
WHERE commentHex = $1;
|
WHERE commentHex = $1;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, commentHex)
|
row := db.QueryRow(statement, commentHex)
|
||||||
|
|
||||||
var domain string
|
var domain string
|
||||||
if err = row.Scan(&domain); err != nil {
|
var deleted bool
|
||||||
|
if err := row.Scan(&domain, &deleted); err != nil {
|
||||||
// TODO: is this the only error?
|
// TODO: is this the only error?
|
||||||
fmt.Fprintf(w, "error: no such comment found (perhaps it has been deleted?)")
|
fmt.Fprintf(w, "error: no such comment found (perhaps it has been deleted?)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if deleted {
|
||||||
|
fmt.Fprintf(w, "error: that comment has already been deleted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := emailGetByUnsubscribeSecretHex(unsubscribeSecretHex)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "error: %v", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isModerator, err := isDomainModerator(domain, e.Email)
|
isModerator, err := isDomainModerator(domain, e.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("error checking if %s is a moderator: %v", e.Email, err)
|
logger.Errorf("error checking if %s is a moderator: %v", e.Email, err)
|
||||||
fmt.Fprintf(w, "error checking if %s is a moderator: %v", e.Email, err)
|
fmt.Fprintf(w, "error: %v", errorInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +52,31 @@ func emailModerateHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if action == "approve" {
|
// Do not use commenterGetByEmail here because we don't know which provider
|
||||||
|
// should be used. This was poor design on multiple fronts on my part, but
|
||||||
|
// let's deal with that later. For now, it suffices to match the
|
||||||
|
// deleter/approver with any account owned by the same email.
|
||||||
|
statement = `
|
||||||
|
SELECT commenterHex
|
||||||
|
FROM commenters
|
||||||
|
WHERE email = $1;
|
||||||
|
`
|
||||||
|
row = db.QueryRow(statement, e.Email)
|
||||||
|
|
||||||
|
var commenterHex string
|
||||||
|
if err = row.Scan(&commenterHex); err != nil {
|
||||||
|
logger.Errorf("cannot retrieve commenterHex by email %q: %v", e.Email, err)
|
||||||
|
fmt.Fprintf(w, "error: %v", errorInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "approve":
|
||||||
err = commentApprove(commentHex)
|
err = commentApprove(commentHex)
|
||||||
} else {
|
case "delete":
|
||||||
err = commentDelete(commentHex)
|
err = commentDelete(commentHex, commenterHex)
|
||||||
|
default:
|
||||||
|
err = errorInvalidAction
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import ()
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type emailNotification struct {
|
type emailNotification struct {
|
||||||
Email string
|
Email string
|
||||||
@@ -13,69 +11,3 @@ type emailNotification struct {
|
|||||||
CommentHex string
|
CommentHex string
|
||||||
Kind string
|
Kind string
|
||||||
}
|
}
|
||||||
|
|
||||||
var emailQueue map[string](chan emailNotification) = map[string](chan emailNotification){}
|
|
||||||
|
|
||||||
func emailNotificationPendingResetAll() error {
|
|
||||||
statement := `
|
|
||||||
UPDATE emails
|
|
||||||
SET pendingEmails = 0;
|
|
||||||
`
|
|
||||||
_, err := db.Exec(statement)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot reset pendingEmails: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func emailNotificationPendingIncrement(email string) error {
|
|
||||||
statement := `
|
|
||||||
UPDATE emails
|
|
||||||
SET pendingEmails = pendingEmails + 1
|
|
||||||
WHERE email = $1;
|
|
||||||
`
|
|
||||||
_, err := db.Exec(statement, email)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot increment pendingEmails: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func emailNotificationPendingReset(email string) error {
|
|
||||||
statement := `
|
|
||||||
UPDATE emails
|
|
||||||
SET pendingEmails = 0, lastEmailNotificationDate = $2
|
|
||||||
WHERE email = $1;
|
|
||||||
`
|
|
||||||
_, err := db.Exec(statement, email, time.Now().UTC())
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot decrement pendingEmails: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func emailNotificationEnqueue(e emailNotification) error {
|
|
||||||
if err := emailNotificationPendingIncrement(e.Email); err != nil {
|
|
||||||
logger.Errorf("cannot increment pendingEmails when enqueueing: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := emailQueue[e.Email]; !ok {
|
|
||||||
// don't enqueue more than 10 emails as we won't send more than 10 comments
|
|
||||||
// in one email anyway
|
|
||||||
emailQueue[e.Email] = make(chan emailNotification, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case emailQueue[e.Email] <- e:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package main
|
|||||||
|
|
||||||
import ()
|
import ()
|
||||||
|
|
||||||
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, state string) {
|
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, html string, state string) {
|
||||||
if d.EmailNotificationPolicy == "none" {
|
if d.EmailNotificationPolicy == "none" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We'll need to check again when we're sending in case the comment was
|
|
||||||
// approved midway anyway.
|
|
||||||
if d.EmailNotificationPolicy == "pending-moderation" && state == "approved" {
|
if d.EmailNotificationPolicy == "pending-moderation" && state == "approved" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,20 +37,37 @@ func emailNotificationModerator(d domain, path string, title string, commenterHe
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
emailNotificationPendingIncrement(m.Email)
|
e, err := emailGet(m.Email)
|
||||||
emailNotificationEnqueue(emailNotification{
|
if err != nil {
|
||||||
Email: m.Email,
|
// No such email.
|
||||||
CommenterName: commenterName,
|
continue
|
||||||
Domain: d.Domain,
|
}
|
||||||
Path: path,
|
|
||||||
Title: title,
|
if !e.SendModeratorNotifications {
|
||||||
CommentHex: commentHex,
|
continue
|
||||||
Kind: kind,
|
}
|
||||||
})
|
|
||||||
|
statement := `
|
||||||
|
SELECT name
|
||||||
|
FROM commenters
|
||||||
|
WHERE email = $1;
|
||||||
|
`
|
||||||
|
row := db.QueryRow(statement, m.Email)
|
||||||
|
var name string
|
||||||
|
if err := row.Scan(&name); err != nil {
|
||||||
|
// The moderator has probably not created a commenter account.
|
||||||
|
// We should only send emails to people who signed up, so skip.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := smtpEmailNotification(m.Email, name, kind, d.Domain, path, commentHex, commenterName, title, html, e.UnsubscribeSecretHex); err != nil {
|
||||||
|
logger.Errorf("error sending email to %s: %v", m.Email, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex string, parentHex string, state string) {
|
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex string, html string, parentHex string, state string) {
|
||||||
// No reply notifications for root comments.
|
// No reply notifications for root comments.
|
||||||
if parentHex == "root" {
|
if parentHex == "root" {
|
||||||
return
|
return
|
||||||
@@ -105,20 +120,20 @@ func emailNotificationReply(d domain, path string, title string, commenterHex st
|
|||||||
commenterName = c.Name
|
commenterName = c.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// We'll check if they want to receive reply notifications later at the time
|
epc, err := emailGet(pc.Email)
|
||||||
// of sending.
|
if err != nil {
|
||||||
emailNotificationEnqueue(emailNotification{
|
// No such email.
|
||||||
Email: pc.Email,
|
return
|
||||||
CommenterName: commenterName,
|
|
||||||
Domain: d.Domain,
|
|
||||||
Path: path,
|
|
||||||
Title: title,
|
|
||||||
CommentHex: commentHex,
|
|
||||||
Kind: "reply",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func emailNotificationNew(d domain, path string, commenterHex string, commentHex string, parentHex string, state string) {
|
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)
|
p, err := pageGet(d.Domain, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("cannot get page to send email notification: %v", err)
|
logger.Errorf("cannot get page to send email notification: %v", err)
|
||||||
@@ -128,11 +143,12 @@ func emailNotificationNew(d domain, path string, commenterHex string, commentHex
|
|||||||
if p.Title == "" {
|
if p.Title == "" {
|
||||||
p.Title, err = pageTitleUpdate(d.Domain, path)
|
p.Title, err = pageTitleUpdate(d.Domain, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("cannot update/get page title to send email notification: %v", err)
|
// Not being able to update a page title isn't serious enough to skip an
|
||||||
return
|
// email notification.
|
||||||
|
p.Title = d.Domain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, state)
|
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, html, state)
|
||||||
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, parentHex, state)
|
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, html, parentHex, state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
func emailNotificationSend(em string, kind string, notifications []emailNotification) {
|
|
||||||
if len(notifications) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e, err := emailGet(em)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot get email: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := []emailNotificationText{}
|
|
||||||
for _, notification := range notifications {
|
|
||||||
statement := `
|
|
||||||
SELECT html
|
|
||||||
FROM comments
|
|
||||||
WHERE commentHex = $1;
|
|
||||||
`
|
|
||||||
row := db.QueryRow(statement, notification.CommentHex)
|
|
||||||
|
|
||||||
var html string
|
|
||||||
if err = row.Scan(&html); err != nil {
|
|
||||||
// the comment was deleted?
|
|
||||||
// TODO: is this the only error?
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = append(messages, emailNotificationText{
|
|
||||||
emailNotification: notification,
|
|
||||||
Html: template.HTML(html),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
statement := `
|
|
||||||
SELECT name
|
|
||||||
FROM commenters
|
|
||||||
WHERE email = $1;
|
|
||||||
`
|
|
||||||
row := db.QueryRow(statement, em)
|
|
||||||
|
|
||||||
var name string
|
|
||||||
if err := row.Scan(&name); err != nil {
|
|
||||||
// The moderator has probably not created a commenter account. Let's just
|
|
||||||
// use their email as name.
|
|
||||||
name = nameFromEmail(em)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := emailNotificationPendingReset(em); err != nil {
|
|
||||||
logger.Errorf("cannot reset after email notification: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := smtpEmailNotification(em, name, e.UnsubscribeSecretHex, messages, kind); err != nil {
|
|
||||||
logger.Errorf("cannot send email notification: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,6 +37,7 @@ var errorNotModerator = errors.New("You need to be a moderator to do that.")
|
|||||||
var errorNotADirectory = errors.New("The given path is not a directory.")
|
var errorNotADirectory = errors.New("The given path is not a directory.")
|
||||||
var errorGzip = errors.New("Cannot GZip content.")
|
var errorGzip = errors.New("Cannot GZip content.")
|
||||||
var errorCannotDownloadDisqus = errors.New("We could not download your Disqus export file.")
|
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 errorSelfVote = errors.New("You cannot vote on your own comment.")
|
||||||
var errorInvalidConfigFile = errors.New("Invalid config file.")
|
var errorInvalidConfigFile = errors.New("Invalid config file.")
|
||||||
var errorInvalidConfigValue = errors.New("Invalid config value.")
|
var errorInvalidConfigValue = errors.New("Invalid config value.")
|
||||||
@@ -44,3 +45,11 @@ var errorNewOwnerForbidden = errors.New("New user registrations are forbidden an
|
|||||||
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
|
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
|
||||||
var errorDatabaseMigration = errors.New("Encountered error applying database migration.")
|
var errorDatabaseMigration = errors.New("Encountered error applying database migration.")
|
||||||
var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.")
|
var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.")
|
||||||
|
var errorEmptyPaths = errors.New("Empty paths field.")
|
||||||
|
var errorInvalidDomain = errors.New("Invalid domain name. Do not include the URL path after the forward slash.")
|
||||||
|
var errorInvalidEntity = errors.New("That entity does not exist.")
|
||||||
|
var errorCannotDeleteOwnerWithActiveDomains = errors.New("You cannot delete your account until all domains associated with your account are deleted.")
|
||||||
|
var errorNoSuchOwner = errors.New("No such owner.")
|
||||||
|
var errorCannotUpdateOauthProfile = errors.New("You cannot update the profile of an external account managed by third-party log in. Please use the appropriate platform to update your details.")
|
||||||
|
var errorUnsupportedCommentoImportVersion = errors.New("Unsupported Commento import format version.")
|
||||||
|
var errorInvalidAction = errors.New("Invalid action.")
|
||||||
|
|||||||
97
api/forgot.go
Normal file
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() {
|
func main() {
|
||||||
exitIfError(loggerCreate())
|
exitIfError(loggerCreate())
|
||||||
|
exitIfError(versionPrint())
|
||||||
exitIfError(configParse())
|
exitIfError(configParse())
|
||||||
exitIfError(dbConnect(5))
|
exitIfError(dbConnect(5))
|
||||||
exitIfError(migrate())
|
exitIfError(migrate())
|
||||||
@@ -9,12 +10,11 @@ func main() {
|
|||||||
exitIfError(smtpTemplatesLoad())
|
exitIfError(smtpTemplatesLoad())
|
||||||
exitIfError(oauthConfigure())
|
exitIfError(oauthConfigure())
|
||||||
exitIfError(markdownRendererCreate())
|
exitIfError(markdownRendererCreate())
|
||||||
exitIfError(emailNotificationPendingResetAll())
|
|
||||||
exitIfError(emailNotificationBegin())
|
|
||||||
exitIfError(sigintCleanupSetup())
|
exitIfError(sigintCleanupSetup())
|
||||||
exitIfError(versionCheckStart())
|
exitIfError(versionCheckStart())
|
||||||
exitIfError(domainExportCleanupBegin())
|
exitIfError(domainExportCleanupBegin())
|
||||||
exitIfError(viewsCleanupBegin())
|
exitIfError(viewsCleanupBegin())
|
||||||
|
exitIfError(ssoTokenCleanupBegin())
|
||||||
|
|
||||||
exitIfError(routesServe())
|
exitIfError(routesServe())
|
||||||
}
|
}
|
||||||
|
|||||||
15
api/oauth.go
15
api/oauth.go
@@ -2,18 +2,27 @@ package main
|
|||||||
|
|
||||||
import ()
|
import ()
|
||||||
|
|
||||||
var configuredOauths []string
|
var googleConfigured bool
|
||||||
|
var twitterConfigured bool
|
||||||
|
var githubConfigured bool
|
||||||
|
var gitlabConfigured bool
|
||||||
|
|
||||||
func oauthConfigure() error {
|
func oauthConfigure() error {
|
||||||
configuredOauths = []string{}
|
|
||||||
|
|
||||||
if err := googleOauthConfigure(); err != nil {
|
if err := googleOauthConfigure(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := twitterOauthConfigure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := githubOauthConfigure(); err != nil {
|
if err := githubOauthConfigure(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := gitlabOauthConfigure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func githubOauthConfigure() error {
|
|||||||
Endpoint: github.Endpoint,
|
Endpoint: github.Endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
configuredOauths = append(configuredOauths, "github")
|
githubConfigured = true
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Get("https://api.github.com/user?access_token=" + token.AccessToken)
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
contents, err := ioutil.ReadAll(resp.Body)
|
contents, err := ioutil.ReadAll(resp.Body)
|
||||||
@@ -80,6 +84,21 @@ func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
email = user["email"].(string)
|
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)
|
c, err := commenterGetByEmail("github", email)
|
||||||
if err != nil && err != errorNoSuchCommenter {
|
if err != nil && err != errorNoSuchCommenter {
|
||||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||||
@@ -88,21 +107,18 @@ func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var commenterHex string
|
var commenterHex string
|
||||||
|
|
||||||
// TODO: in case of returning users, update the information we have on record?
|
|
||||||
if err == errorNoSuchCommenter {
|
if err == errorNoSuchCommenter {
|
||||||
var link string
|
commenterHex, err = commenterNew(email, name, link, photo, "github", "")
|
||||||
if val, ok := user["html_url"]; ok {
|
|
||||||
link = val.(string)
|
|
||||||
} else {
|
|
||||||
link = "undefined"
|
|
||||||
}
|
|
||||||
|
|
||||||
commenterHex, err = commenterNew(email, user["name"].(string), link, user["avatar_url"].(string), "github", "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
commenterHex = c.CommenterHex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
Endpoint: google.Endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
configuredOauths = append(configuredOauths, "google")
|
googleConfigured = true
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,23 +52,32 @@ func googleCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var commenterHex string
|
name := user["name"].(string)
|
||||||
|
|
||||||
// TODO: in case of returning users, update the information we have on record?
|
link := "undefined"
|
||||||
if err == errorNoSuchCommenter {
|
if user["link"] != nil {
|
||||||
var link string
|
link = user["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", "")
|
photo := "undefined"
|
||||||
|
if user["picture"] != nil {
|
||||||
|
photo = user["picture"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var commenterHex string
|
||||||
|
|
||||||
|
if err == errorNoSuchCommenter {
|
||||||
|
commenterHex, err = commenterNew(email, name, link, photo, "google", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
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)
|
||||||
|
}
|
||||||
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 ()
|
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) {
|
func ownerGetByEmail(email string) (owner, error) {
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return owner{}, errorMissingField
|
return owner{}, errorMissingField
|
||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT ownerHex, email, name, confirmedEmail, joinDate
|
SELECT ` + ownersRowColumns + `
|
||||||
FROM owners
|
FROM owners
|
||||||
WHERE email=$1;
|
WHERE email=$1;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, email)
|
row := db.QueryRow(statement, email)
|
||||||
|
|
||||||
var o owner
|
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.
|
// TODO: Make sure this is actually no such email.
|
||||||
return owner{}, errorNoSuchEmail
|
return owner{}, errorNoSuchEmail
|
||||||
}
|
}
|
||||||
@@ -29,17 +47,17 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT ownerHex, email, name, confirmedEmail, joinDate
|
SELECT ` + ownersRowColumns + `
|
||||||
FROM owners
|
FROM owners
|
||||||
WHERE ownerHex IN (
|
WHERE owners.ownerHex IN (
|
||||||
SELECT ownerHex FROM ownerSessions
|
SELECT ownerSessions.ownerHex FROM ownerSessions
|
||||||
WHERE ownerToken = $1
|
WHERE ownerSessions.ownerToken = $1
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, ownerToken)
|
row := db.QueryRow(statement, ownerToken)
|
||||||
|
|
||||||
var o owner
|
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)
|
logger.Errorf("cannot scan owner: %v\n", err)
|
||||||
return owner{}, errorInternal
|
return owner{}, errorInternal
|
||||||
}
|
}
|
||||||
@@ -53,14 +71,14 @@ func ownerGetByOwnerHex(ownerHex string) (owner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statement := `
|
statement := `
|
||||||
SELECT ownerHex, email, name, confirmedEmail, joinDate
|
SELECT ` + ownersRowColumns + `
|
||||||
FROM owners
|
FROM owners
|
||||||
WHERE ownerHex = $1;
|
WHERE ownerHex = $1;
|
||||||
`
|
`
|
||||||
row := db.QueryRow(statement, ownerHex)
|
row := db.QueryRow(statement, ownerHex)
|
||||||
|
|
||||||
var o owner
|
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)
|
logger.Errorf("cannot scan owner: %v\n", err)
|
||||||
return owner{}, errorInternal
|
return owner{}, errorInternal
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,10 +92,8 @@ func ownerNewHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := commenterNew(*x.Email, *x.Name, "undefined", "undefined", "commento", *x.Password); err != nil {
|
// Errors in creating a commenter account should not hold this up.
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
_, _ = commenterNew(*x.Email, *x.Name, "undefined", "undefined", "commento", *x.Password)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true, "confirmEmail": smtpConfigured})
|
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,73 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ownerResetPassword(resetHex string, password string) error {
|
|
||||||
if resetHex == "" || password == "" {
|
|
||||||
return errorMissingField
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot generate hash from password: %v\n", err)
|
|
||||||
return errorInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
statement := `
|
|
||||||
UPDATE owners SET passwordHash=$1
|
|
||||||
WHERE ownerHex = (
|
|
||||||
SELECT ownerHex
|
|
||||||
FROM ownerResetHexes
|
|
||||||
WHERE resetHex=$2
|
|
||||||
);
|
|
||||||
`
|
|
||||||
res, err := db.Exec(statement, string(passwordHash), resetHex)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot change user's password: %v\n", err)
|
|
||||||
return errorInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := res.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("cannot count rows affected: %v\n", err)
|
|
||||||
return errorInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
return errorNoSuchResetToken
|
|
||||||
}
|
|
||||||
|
|
||||||
statement = `
|
|
||||||
DELETE FROM ownerResetHexes
|
|
||||||
WHERE resetHex=$1;
|
|
||||||
`
|
|
||||||
_, err = db.Exec(statement, resetHex)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("cannot remove reset token: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ownerResetPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
type request struct {
|
|
||||||
ResetHex *string `json:"resetHex"`
|
|
||||||
Password *string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var x request
|
|
||||||
if err := bodyUnmarshal(r, &x); err != nil {
|
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ownerResetPassword(*x.ResetHex, *x.Password); err != nil {
|
|
||||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyMarshal(w, response{"success": true})
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOwnerResetPasswordBasics(t *testing.T) {
|
|
||||||
failTestOnError(t, setupTestEnv())
|
|
||||||
|
|
||||||
ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2")
|
|
||||||
|
|
||||||
resetHex, _ := randomHex(32)
|
|
||||||
|
|
||||||
statement := `
|
|
||||||
INSERT INTO
|
|
||||||
ownerResetHexes (resetHex, ownerHex, sendDate)
|
|
||||||
VALUES ($1, $2, $3 );
|
|
||||||
`
|
|
||||||
_, err := db.Exec(statement, resetHex, ownerHex, time.Now().UTC())
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unexpected error inserting resetHex: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = ownerResetPassword(resetHex, "hunter3"); err != nil {
|
|
||||||
t.Errorf("unexpected error resetting password: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := ownerLogin("test@example.com", "hunter2"); err == nil {
|
|
||||||
t.Errorf("expected error not found when given old password")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := ownerLogin("test@example.com", "hunter3"); err != nil {
|
|
||||||
t.Errorf("unexpected error when logging in: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
82
api/reset.go
Normal file
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,18 +8,20 @@ func apiRouterInit(router *mux.Router) error {
|
|||||||
router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST")
|
router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET")
|
router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET")
|
||||||
router.HandleFunc("/api/owner/login", ownerLoginHandler).Methods("POST")
|
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/self", ownerSelfHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/owner/delete", ownerDeleteHandler).Methods("POST")
|
||||||
|
|
||||||
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
|
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/delete", domainDeleteHandler).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/list", domainListHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
|
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
|
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
|
router.HandleFunc("/api/domain/moderator/delete", domainModeratorDeleteHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST")
|
router.HandleFunc("/api/domain/statistics", domainStatisticsHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/import/disqus", domainImportDisqusHandler).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/begin", domainExportBeginHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/domain/export/download", domainExportDownloadHandler).Methods("GET")
|
router.HandleFunc("/api/domain/export/download", domainExportDownloadHandler).Methods("GET")
|
||||||
|
|
||||||
@@ -27,6 +29,11 @@ func apiRouterInit(router *mux.Router) error {
|
|||||||
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
|
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
|
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/commenter/self", commenterSelfHandler).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/get", emailGetHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/email/update", emailUpdateHandler).Methods("POST")
|
router.HandleFunc("/api/email/update", emailUpdateHandler).Methods("POST")
|
||||||
@@ -38,7 +45,17 @@ func apiRouterInit(router *mux.Router) error {
|
|||||||
router.HandleFunc("/api/oauth/github/redirect", githubRedirectHandler).Methods("GET")
|
router.HandleFunc("/api/oauth/github/redirect", githubRedirectHandler).Methods("GET")
|
||||||
router.HandleFunc("/api/oauth/github/callback", githubCallbackHandler).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/new", commentNewHandler).Methods("POST")
|
||||||
|
router.HandleFunc("/api/comment/edit", commentEditHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
|
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")
|
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")
|
||||||
router.HandleFunc("/api/comment/vote", commentVoteHandler).Methods("POST")
|
router.HandleFunc("/api/comment/vote", commentVoteHandler).Methods("POST")
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func fileDetemplate(f string) ([]byte, error) {
|
|||||||
x = strings.Replace(x, "[[[.Origin]]]", os.Getenv("ORIGIN"), -1)
|
x = strings.Replace(x, "[[[.Origin]]]", os.Getenv("ORIGIN"), -1)
|
||||||
x = strings.Replace(x, "[[[.CdnPrefix]]]", os.Getenv("CDN_PREFIX"), -1)
|
x = strings.Replace(x, "[[[.CdnPrefix]]]", os.Getenv("CDN_PREFIX"), -1)
|
||||||
x = strings.Replace(x, "[[[.Footer]]]", footer, -1)
|
x = strings.Replace(x, "[[[.Footer]]]", footer, -1)
|
||||||
|
x = strings.Replace(x, "[[[.Version]]]", version, -1)
|
||||||
|
|
||||||
return []byte(x), nil
|
return []byte(x), nil
|
||||||
}
|
}
|
||||||
@@ -95,12 +96,14 @@ func staticRouterInit(router *mux.Router) error {
|
|||||||
pages := []string{
|
pages := []string{
|
||||||
"/login",
|
"/login",
|
||||||
"/forgot",
|
"/forgot",
|
||||||
"/reset-password",
|
"/reset",
|
||||||
"/signup",
|
"/signup",
|
||||||
"/confirm-email",
|
"/confirm-email",
|
||||||
"/unsubscribe",
|
"/unsubscribe",
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
|
"/settings",
|
||||||
"/logout",
|
"/logout",
|
||||||
|
"/profile",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
@@ -116,7 +119,7 @@ func staticRouterInit(router *mux.Router) error {
|
|||||||
if path.Ext(p) != "" {
|
if path.Ext(p) != "" {
|
||||||
contentType[p] = mime.TypeByExtension(path.Ext(p))
|
contentType[p] = mime.TypeByExtension(path.Ext(p))
|
||||||
} else {
|
} else {
|
||||||
contentType[p] = mime.TypeByExtension("html")
|
contentType[p] = "text/html; charset=utf-8"
|
||||||
}
|
}
|
||||||
|
|
||||||
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
|
router.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func sigintCleanup() int {
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func smtpConfigure() error {
|
|||||||
password := os.Getenv("SMTP_PASSWORD")
|
password := os.Getenv("SMTP_PASSWORD")
|
||||||
host := os.Getenv("SMTP_HOST")
|
host := os.Getenv("SMTP_HOST")
|
||||||
port := os.Getenv("SMTP_PORT")
|
port := os.Getenv("SMTP_PORT")
|
||||||
if username == "" || password == "" || host == "" || port == "" {
|
if host == "" || port == "" {
|
||||||
logger.Warningf("smtp not configured, no emails will be sent")
|
logger.Warningf("smtp not configured, no emails will be sent")
|
||||||
smtpConfigured = false
|
smtpConfigured = false
|
||||||
return nil
|
return nil
|
||||||
@@ -26,7 +26,11 @@ func smtpConfigure() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("configuring smtp: %s", host)
|
logger.Infof("configuring smtp: %s", 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)
|
smtpAuth = smtp.PlainAuth("", username, password, host)
|
||||||
|
}
|
||||||
smtpConfigured = true
|
smtpConfigured = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,43 +9,19 @@ import (
|
|||||||
tt "text/template"
|
tt "text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
type emailNotificationText struct {
|
|
||||||
emailNotification
|
|
||||||
Html ht.HTML
|
|
||||||
}
|
|
||||||
|
|
||||||
type emailNotificationPlugs struct {
|
type emailNotificationPlugs struct {
|
||||||
Origin string
|
Origin string
|
||||||
Kind string
|
Kind string
|
||||||
Subject string
|
|
||||||
UnsubscribeSecretHex string
|
UnsubscribeSecretHex string
|
||||||
Notifications []emailNotificationText
|
Domain string
|
||||||
}
|
Path string
|
||||||
|
CommentHex string
|
||||||
func smtpEmailNotification(to string, toName string, unsubscribeSecretHex string, notifications []emailNotificationText, kind string) error {
|
CommenterName string
|
||||||
var subject string
|
Title string
|
||||||
if kind == "reply" {
|
Html ht.HTML
|
||||||
var verb string
|
|
||||||
if len(notifications) > 1 {
|
|
||||||
verb = "replies"
|
|
||||||
} else {
|
|
||||||
verb = "reply"
|
|
||||||
}
|
|
||||||
subject = fmt.Sprintf("%d new comment %s", len(notifications), verb)
|
|
||||||
} else {
|
|
||||||
var verb string
|
|
||||||
if len(notifications) > 1 {
|
|
||||||
verb = "comments"
|
|
||||||
} else {
|
|
||||||
verb = "comment"
|
|
||||||
}
|
|
||||||
if kind == "pending-moderation" {
|
|
||||||
subject = fmt.Sprintf("%d new %s pending moderation", len(notifications), verb)
|
|
||||||
} else {
|
|
||||||
subject = fmt.Sprintf("%d new %s on your website", len(notifications), verb)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func smtpEmailNotification(to string, toName string, kind string, domain string, path string, commentHex string, commenterName string, title string, html string, unsubscribeSecretHex string) error {
|
||||||
h, err := tt.New("header").Parse(`MIME-Version: 1.0
|
h, err := tt.New("header").Parse(`MIME-Version: 1.0
|
||||||
From: Commento <{{.FromAddress}}>
|
From: Commento <{{.FromAddress}}>
|
||||||
To: {{.ToName}} <{{.ToAddress}}>
|
To: {{.ToName}} <{{.ToAddress}}>
|
||||||
@@ -53,9 +29,8 @@ Content-Type: text/html; charset=UTF-8
|
|||||||
Subject: {{.Subject}}
|
Subject: {{.Subject}}
|
||||||
|
|
||||||
`)
|
`)
|
||||||
|
|
||||||
var header bytes.Buffer
|
var header bytes.Buffer
|
||||||
h.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "[Commento] " + subject})
|
h.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "[Commento] " + title})
|
||||||
|
|
||||||
t, err := ht.ParseFiles(fmt.Sprintf("%s/templates/email-notification.txt", os.Getenv("STATIC")))
|
t, err := ht.ParseFiles(fmt.Sprintf("%s/templates/email-notification.txt", os.Getenv("STATIC")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,9 +42,13 @@ Subject: {{.Subject}}
|
|||||||
err = t.Execute(&body, &emailNotificationPlugs{
|
err = t.Execute(&body, &emailNotificationPlugs{
|
||||||
Origin: os.Getenv("ORIGIN"),
|
Origin: os.Getenv("ORIGIN"),
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Subject: subject,
|
Domain: domain,
|
||||||
|
Path: path,
|
||||||
|
CommentHex: commentHex,
|
||||||
|
CommenterName: commenterName,
|
||||||
|
Title: title,
|
||||||
|
Html: ht.HTML(html),
|
||||||
UnsubscribeSecretHex: unsubscribeSecretHex,
|
UnsubscribeSecretHex: unsubscribeSecretHex,
|
||||||
Notifications: notifications,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("error generating templated HTML for email notification: %v", err)
|
logger.Errorf("error generating templated HTML for email notification: %v", err)
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ownerResetHexPlugs struct {
|
type resetHexPlugs struct {
|
||||||
Origin string
|
Origin string
|
||||||
ResetHex string
|
ResetHex string
|
||||||
}
|
}
|
||||||
|
|
||||||
func smtpOwnerResetHex(to string, toName string, resetHex string) error {
|
func smtpResetHex(to string, toName string, resetHex string) error {
|
||||||
var header bytes.Buffer
|
var header bytes.Buffer
|
||||||
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"})
|
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"})
|
||||||
|
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
templates["reset-hex"].Execute(&body, &ownerResetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex})
|
templates["reset-hex"].Execute(&body, &resetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex})
|
||||||
|
|
||||||
err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
|
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 {
|
if err != nil {
|
||||||
@@ -2,9 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/op/go-logging"
|
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/op/go-logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
func failTestOnError(t *testing.T, err error) {
|
func failTestOnError(t *testing.T, err error) {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func htmlTitleRecurse(h *html.Node) string {
|
func htmlTitleRecurse(h *html.Node) string {
|
||||||
|
if h == nil || h.FirstChild == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
if h.Type == html.ElementNode && h.Data == "title" {
|
if h.Type == html.ElementNode && h.Data == "title" {
|
||||||
return h.FirstChild.Data
|
return h.FirstChild.Data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,6 @@ func concat(a bytes.Buffer, b bytes.Buffer) []byte {
|
|||||||
return append(a.Bytes(), b.Bytes()...)
|
return append(a.Bytes(), b.Bytes()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nameFromEmail(email string) string {
|
|
||||||
for i, c := range email {
|
|
||||||
if c == '@' {
|
|
||||||
return email[:i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return email
|
|
||||||
}
|
|
||||||
|
|
||||||
func exitIfError(err error) {
|
func exitIfError(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("fatal error: %v\n", err)
|
fmt.Printf("fatal error: %v\n", err)
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ func emailStrip(email string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var https = regexp.MustCompile(`(https?://)`)
|
var https = regexp.MustCompile(`(https?://)`)
|
||||||
var trailingSlash = regexp.MustCompile(`(/*$)`)
|
var domainTrail = regexp.MustCompile(`(/.*$)`)
|
||||||
|
|
||||||
func domainStrip(domain string) string {
|
func domainStrip(domain string) string {
|
||||||
noSlash := trailingSlash.ReplaceAllString(domain, ``)
|
noProtocol := https.ReplaceAllString(domain, ``)
|
||||||
noProtocol := https.ReplaceAllString(noSlash, ``)
|
noTrail := domainTrail.ReplaceAllString(noProtocol, ``)
|
||||||
|
|
||||||
return noProtocol
|
return noTrail
|
||||||
}
|
}
|
||||||
|
|
||||||
var pathMatch = regexp.MustCompile(`(https?://[^/]*)`)
|
var pathMatch = regexp.MustCompile(`(https?://[^/]*)`)
|
||||||
|
|||||||
11
api/utils_sql.go
Normal file
11
api/utils_sql.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import ()
|
||||||
|
|
||||||
|
// scanner is a database/sql abstraction interface that can be used with both
|
||||||
|
// *sql.Row and *sql.Rows.
|
||||||
|
type sqlScanner interface {
|
||||||
|
// Scan copies columns from the underlying query row(s) to the values
|
||||||
|
// pointed to by dest.
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func versionPrint() error {
|
||||||
|
logger.Infof("starting Commento %s", version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func versionCheckStart() error {
|
func versionCheckStart() error {
|
||||||
go func() {
|
go func() {
|
||||||
printedError := false
|
printedError := false
|
||||||
|
|||||||
16
db/20190418210855-configurable-auth.sql
Normal file
16
db/20190418210855-configurable-auth.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Make all login providers optional (but enabled by default)
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD commentoProvider BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD googleProvider BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD twitterProvider BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD githubProvider BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD gitlabProvider BOOLEAN NOT NULL DEFAULT true;
|
||||||
10
db/20190420181913-sso.sql
Normal file
10
db/20190420181913-sso.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Single Sign-On (SSO)
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD ssoProvider BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD ssoSecret TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD ssoUrl TEXT NOT NULL DEFAULT '';
|
||||||
6
db/20190420231030-sso-tokens.sql
Normal file
6
db/20190420231030-sso-tokens.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ssoTokens (
|
||||||
|
token TEXT NOT NULL UNIQUE PRIMARY KEY ,
|
||||||
|
domain TEXT NOT NULL ,
|
||||||
|
commenterToken TEXT NOT NULL ,
|
||||||
|
creationDate TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
2
db/20190501201032-v1.7.0.sql
Normal file
2
db/20190501201032-v1.7.0.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE config
|
||||||
|
SET version = 'v1.7.0';
|
||||||
13
db/20190505191006-comment-count-decrease.sql
Normal file
13
db/20190505191006-comment-count-decrease.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- This trigger is called every time a comment is deleted, so the comment count for the page where the comment belong is updated
|
||||||
|
CREATE OR REPLACE FUNCTION commentsDeleteTriggerFunction() RETURNS TRIGGER AS $trigger$
|
||||||
|
BEGIN
|
||||||
|
UPDATE pages
|
||||||
|
SET commentCount = commentCount - 1
|
||||||
|
WHERE domain = old.domain AND path = old.path;
|
||||||
|
|
||||||
|
DELETE FROM comments
|
||||||
|
WHERE parentHex = old.commentHex;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$trigger$ LANGUAGE plpgsql;
|
||||||
2
db/20190508222848-reset-count.sql
Normal file
2
db/20190508222848-reset-count.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE pages
|
||||||
|
SET commentCount = commentCount + 1;
|
||||||
8
db/20190606000842-reset-hex.sql
Normal file
8
db/20190606000842-reset-hex.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Create the resetHexes table
|
||||||
|
|
||||||
|
ALTER TABLE ownerResetHexes RENAME TO resetHexes;
|
||||||
|
|
||||||
|
ALTER TABLE resetHexes RENAME ownerHex TO hex;
|
||||||
|
|
||||||
|
ALTER TABLE resetHexes
|
||||||
|
ADD entity TEXT NOT NULL DEFAULT 'owner';
|
||||||
6
db/20190913175445-delete-comments.sql
Normal file
6
db/20190913175445-delete-comments.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
DROP TRIGGER IF EXISTS commentsDeleteTrigger ON comments;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS commentsDeleteTriggerFunction();
|
||||||
|
|
||||||
|
ALTER TABLE comments
|
||||||
|
ADD deleted BOOLEAN NOT NULL DEFAULT false;
|
||||||
10
db/20191204173000-sort-method.sql
Normal file
10
db/20191204173000-sort-method.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Default sort policy for each domain
|
||||||
|
|
||||||
|
CREATE TYPE sortPolicy AS ENUM (
|
||||||
|
'score-desc',
|
||||||
|
'creationdate-desc',
|
||||||
|
'creationdate-asc'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD defaultSortPolicy sortPolicy NOT NULL DEFAULT 'score-desc';
|
||||||
2
db/20210228122203-comment-delete-log.sql
Normal file
2
db/20210228122203-comment-delete-log.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE comments ADD deleterHex TEXT;
|
||||||
|
ALTER TABLE comments ADD deletionDate TIMESTAMP;
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
<script src="[[[.CdnPrefix]]]/js/jquery.js"></script>
|
<script src="[[[.CdnPrefix]]]/js/jquery.js"></script>
|
||||||
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
|
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
|
||||||
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/auth.css">
|
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/auth.css">
|
||||||
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:400,700|Source+Sans+Pro:200,300,400,700" rel="stylesheet">
|
|
||||||
<title>Commento: Email Confirmation</title>
|
<title>Commento: Email Confirmation</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
|
<link rel="icon" href="[[[.CdnPrefix]]]/images/120x120.png">
|
||||||
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/chartist.css">
|
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/chartist.css">
|
||||||
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/dashboard.css">
|
<link rel="stylesheet" href="[[[.CdnPrefix]]]/css/dashboard.css">
|
||||||
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:400,700|Source+Sans+Pro:200,300,400,700" rel="stylesheet">
|
|
||||||
<title>Commento: Dashboard</title>
|
<title>Commento: Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<div id="navbar" class="navbar">
|
<div id="navbar" class="navbar">
|
||||||
<a href="[[[.Origin]]]/" class="navbar-item navbar-logo-text"><img src="[[[.CdnPrefix]]]/images/logo.svg" class="navbar-logo">Commento</a>
|
<a href="[[[.Origin]]]/" class="navbar-item navbar-logo-text"><img src="[[[.CdnPrefix]]]/images/logo.svg" class="navbar-logo">Commento</a>
|
||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
|
<a href="[[[.Origin]]]/settings" class="navbar-item">Settings</a>
|
||||||
<a href="[[[.Origin]]]/logout" class="navbar-item">Logout</a>
|
<a href="[[[.Origin]]]/logout" class="navbar-item">Logout</a>
|
||||||
<div class="float-right"><b><div id="owner-name"></div></b></div>
|
<div class="float-right"><b><div id="owner-name"></div></b></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<div class="normal-text">
|
<div class="normal-text">
|
||||||
Read the Commento documentation <a href="https://docs.commento.io/configuration/">on configuration</a>.
|
Read the Commento documentation <a href="https://docs.commento.io/configuration/frontend/">on configuration</a>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,39 +160,68 @@
|
|||||||
<ul class="tabs">
|
<ul class="tabs">
|
||||||
<li class="tab-link original current" data-tab="mod-tab-1">General</li>
|
<li class="tab-link original current" data-tab="mod-tab-1">General</li>
|
||||||
<li class="tab-link" data-tab="mod-tab-2">Add/Remove Moderators</li>
|
<li class="tab-link" data-tab="mod-tab-2">Add/Remove Moderators</li>
|
||||||
<li class="tab-link" data-tab="mod-tab-3">Email Settings</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div id="mod-tab-1" class="content original current">
|
<div id="mod-tab-1" class="content original current">
|
||||||
|
<div class="question">
|
||||||
|
<div class="title">
|
||||||
|
Comment Filtering
|
||||||
|
</div>
|
||||||
|
<div class="answer">
|
||||||
<div class="row no-border commento-round-check">
|
<div class="row no-border commento-round-check">
|
||||||
<input type="checkbox" v-model="domains[cd].autoSpamFilter" id="spam-filtering">
|
<input type="checkbox" v-model="domains[cd].autoSpamFilter" id="spam-filtering">
|
||||||
<label for="spam-filtering">Automatic spam filtering</label>
|
<label for="spam-filtering">Automatic spam filtering</label>
|
||||||
<div class="pitch">
|
|
||||||
Commento uses Akismet's advanced spam detection to automatically identify and remove spam comments. This is strongly recommended. Requires backend configuration.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row no-border commento-round-check">
|
<div class="row no-border commento-round-check">
|
||||||
<input type="checkbox" v-model="domains[cd].requireModeration" id="require-moderation">
|
<input type="checkbox" v-model="domains[cd].requireModeration" id="require-moderation">
|
||||||
<label for="require-moderation">Require all comments to be approved manually</label>
|
<label for="require-moderation">Require all comments to be approved manually</label>
|
||||||
<div class="pitch">
|
|
||||||
Enabling this would require a moderator to approve all comments. This is generally recommended if your site doesn't receive too much traffic.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row no-border commento-round-check">
|
<div class="row no-border commento-round-check">
|
||||||
<input type="checkbox" v-model="domains[cd].allowAnonymous" id="allow-anonymous">
|
<input type="checkbox" v-model="domains[cd].moderateAllAnonymous" id="moderate-all-anonymous">
|
||||||
<label for="allow-anonymous">Allow anonymous comments</label>
|
<label for="moderate-all-anonymous">Require anonymous comments to be approved manually</label>
|
||||||
<div class="pitch">
|
</div>
|
||||||
Enabling this would allow your readers to comment anonymously. Disabling would require the to authenticate themselves (using their Google account, for example). Recommended.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row no-border commento-round-check indent" v-if="domains[cd].allowAnonymous">
|
<div class="question">
|
||||||
<input type="checkbox" v-model="domains[cd].moderateAllAnonymous" id="moderate-all-anonymous">
|
<div class="title">
|
||||||
<label for="moderate-all-anonymous">Require anonymous comments to be approved manually</label>
|
Email Schedule
|
||||||
<div class="pitch">
|
</div>
|
||||||
Enabling this would require a moderator to approve all anonymous comments. This is recommended if most of your spam comments are from anonymous users.
|
<div class="answer">
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="email-all" value="all" v-model="domains[cd].emailNotificationPolicy">
|
||||||
|
<label for="email-all">Whenever a new comment is created</label>
|
||||||
|
</div>
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="email-pending-moderation" value="pending-moderation" v-model="domains[cd].emailNotificationPolicy">
|
||||||
|
<label for="email-pending-moderation">Only for comments pending moderation</label>
|
||||||
|
</div>
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="email-none" value="none" v-model="domains[cd].emailNotificationPolicy">
|
||||||
|
<label for="email-none">Do not email moderators</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question">
|
||||||
|
<div class="title">
|
||||||
|
Comment Sorting
|
||||||
|
</div>
|
||||||
|
<div class="answer">
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="defaultSortPolicy-score-desc" value="score-desc" v-model="domains[cd].defaultSortPolicy">
|
||||||
|
<label for="defaultSortPolicy-score-desc">Most upvoted first</label>
|
||||||
|
</div>
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="defaultSortPolicy-creationdate-desc" value="creationdate-desc" v-model="domains[cd].defaultSortPolicy">
|
||||||
|
<label for="defaultSortPolicy-creationdate-desc">Newest first</label>
|
||||||
|
</div>
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="defaultSortPolicy-creationdate-asc" value="creationdate-asc" v-model="domains[cd].defaultSortPolicy">
|
||||||
|
<label for="defaultSortPolicy-creationdate-asc">Oldest first</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,31 +252,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mod-tab-3" class="content">
|
|
||||||
<div class="normal-text">
|
|
||||||
You can enable email notifications to notify your moderators when a new comment is posted or when a comment is pending moderation. Commento tries to be smart about how often an email is sent. Emails will be delayed and batched until you go 10 minutes without one. This requires valid SMTP settings in order to send emails.<br><br>
|
|
||||||
</div>
|
|
||||||
<div class="question">
|
|
||||||
When do you want emails sent to moderators?
|
|
||||||
</div>
|
|
||||||
<div class="row no-border commento-round-check indent">
|
|
||||||
<input type="radio" id="email-all" value="all" v-model="domains[cd].emailNotificationPolicy">
|
|
||||||
<label for="email-all">Whenever a new comment is created</label>
|
|
||||||
</div>
|
|
||||||
<div class="row no-border commento-round-check indent">
|
|
||||||
<input type="radio" id="email-pending-moderation" value="pending-moderation" v-model="domains[cd].emailNotificationPolicy">
|
|
||||||
<label for="email-pending-moderation">Only for comments pending moderation</label>
|
|
||||||
</div>
|
|
||||||
<div class="row no-border commento-round-check indent">
|
|
||||||
<input type="radio" id="email-none" value="none" v-model="domains[cd].emailNotificationPolicy">
|
|
||||||
<label for="email-none">Do not email moderators</label>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<div class="center">
|
|
||||||
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,6 +276,91 @@
|
|||||||
<input class="input gray-input" id="cur-domain-name" type="text" :placeholder="domains[cd].origName" v-model="domains[cd].name">
|
<input class="input gray-input" id="cur-domain-name" type="text" :placeholder="domains[cd].origName" v-model="domains[cd].name">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="question">
|
||||||
|
<div class="title">
|
||||||
|
Authentication Options
|
||||||
|
</div>
|
||||||
|
<div class="answer">
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="checkbox" v-model="domains[cd].allowAnonymous" id="allow-anonymous">
|
||||||
|
<label for="allow-anonymous">Anonymous comments</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="checkbox" v-model="domains[cd].commentoProvider" id="commento-provider">
|
||||||
|
<label for="commento-provider">Email address login</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row no-border commento-round-check" v-if="configuredOauths.google">
|
||||||
|
<input type="checkbox" v-model="domains[cd].googleProvider" id="google-provider">
|
||||||
|
<label for="google-provider">Google login</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row no-border commento-round-check" v-if="configuredOauths.twitter">
|
||||||
|
<input type="checkbox" v-model="domains[cd].twitterProvider" id="twitter-provider">
|
||||||
|
<label for="twitter-provider">Twitter login</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row no-border commento-round-check" v-if="configuredOauths.github">
|
||||||
|
<input type="checkbox" v-model="domains[cd].githubProvider" id="github-provider">
|
||||||
|
<label for="github-provider">GitHub login</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row no-border commento-round-check" v-if="configuredOauths.gitlab">
|
||||||
|
<input type="checkbox" v-model="domains[cd].gitlabProvider" id="gitlab-provider">
|
||||||
|
<label for="gitlab-provider">GitLab login</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="checkbox" v-model="domains[cd].ssoProvider" id="sso-provider" @change="window.commento.ssoProviderChangeHandler()">
|
||||||
|
<label for="sso-provider">Single sign-on</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="indent" v-if="domains[cd].ssoProvider">
|
||||||
|
<div class="row">
|
||||||
|
<div class="label">HMAC shared secret key</div>
|
||||||
|
<input class="input gray-input monospace" id="sso-secret" readonly="true" type="text" placeholder="Loading..." v-model="domains[cd].ssoSecret">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="label">Redirect URL</div>
|
||||||
|
<input class="input gray-input" id="sso-url" type="text" :placeholder="domains[cd].ssoUrl" v-model="domains[cd].ssoUrl">
|
||||||
|
</div>
|
||||||
|
<div class="normal-text">
|
||||||
|
<div class="subtext-container">
|
||||||
|
<div class="subtext">
|
||||||
|
Read the Commento documentation <a href="https://docs.commento.io/configuration/frontend/sso.html">on single sign-on</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning" v-if="!domains[cd].allowAnonymous && !domains[cd].commentoProvider && (!configuredOauths.google || !domains[cd].googleProvider) && (!configuredOauths.twitter || !domains[cd].twitterProvider) && (!configuredOauths.github || !domains[cd].githubProvider) && (!configuredOauths.gitlab || !domains[cd].gitlabProvider) && !domains[cd].ssoProvider">
|
||||||
|
You have disabled all authentication options. Your readers will not be able to login, create comments, or vote.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question">
|
||||||
|
<div class="title">
|
||||||
|
Default Comment Sorting
|
||||||
|
</div>
|
||||||
|
<div class="answer">
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="defaultSortPolicy-score-desc" value="score-desc" v-model="domains[cd].defaultSortPolicy">
|
||||||
|
<label for="defaultSortPolicy-score-desc">Most upvoted first</label>
|
||||||
|
</div>
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="defaultSortPolicy-creationdate-desc" value="creationdate-desc" v-model="domains[cd].defaultSortPolicy">
|
||||||
|
<label for="defaultSortPolicy-creationdate-desc">Newest first</label>
|
||||||
|
</div>
|
||||||
|
<div class="row no-border commento-round-check">
|
||||||
|
<input type="radio" id="defaultSortPolicy-creationdate-asc" value="creationdate-asc" v-model="domains[cd].defaultSortPolicy">
|
||||||
|
<label for="defaultSortPolicy-creationdate-asc">Oldest first</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
|
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,10 +390,11 @@
|
|||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<div class="tab">
|
<div class="tab">
|
||||||
<ul class="tabs">
|
<ul class="tabs">
|
||||||
<li class="tab-link original current" data-tab="install-tab-1">Disqus</li>
|
<li class="tab-link original current" data-tab="import-tab-1">Disqus</li>
|
||||||
|
<li class="tab-link" data-tab="import-tab-2">Commento</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div id="install-tab-1" class="content original current">
|
<div id="import-tab-1" class="content original current">
|
||||||
<div class="normal-text">
|
<div class="normal-text">
|
||||||
If you're currently using Disqus, you can import all comments into Commento:
|
If you're currently using Disqus, you can import all comments into Commento:
|
||||||
<ul>
|
<ul>
|
||||||
@@ -344,6 +434,41 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="import-tab-2" class="content">
|
||||||
|
<div class="normal-text">
|
||||||
|
If you've previously exported data from Commento you can restore it:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Upload your exported data file somewhere in the cloud and generate a direct link to it. Ensure that the export file is a GZIP archive of a JSON file.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Copy and paste that link here to start the import process:
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<div class="commento-email-container">
|
||||||
|
<div class="commento-email">
|
||||||
|
<input class="commento-input" type="text" id="commento-url" placeholder="https://example.com/commento.json.gz">
|
||||||
|
<button id="commento-import-button" class="commento-email-button" onclick="window.commento.importCommento()">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Commento will automatically download this file, extract it, parse it and import comments into Commento. URL information, comment authors, text formatting, and nested replies will be preserved.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
It is strongly recommended you do this only once. Importing multiple times may have unintended effects.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,39 +480,67 @@
|
|||||||
<div id="danger-view" class="view hidden">
|
<div id="danger-view" class="view hidden">
|
||||||
<div class="view-inside">
|
<div class="view-inside">
|
||||||
<div class="mid-view">
|
<div class="mid-view">
|
||||||
<div class="tabs-container">
|
<div class="center center-title">
|
||||||
<div class="tab">
|
Danger Zone
|
||||||
<ul class="tabs">
|
|
||||||
<li class="tab-link original current" data-tab="danger-tab-1">Freeze Comments</li>
|
|
||||||
<li class="tab-link current" data-tab="danger-tab-2">Delete Domain</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div id="danger-tab-1" class="content original current">
|
|
||||||
<div class="box" v-if="domains[cd].state == 'frozen'">
|
|
||||||
<div class="box-subtitle">
|
|
||||||
If you desire to re-allow comments again on your website, you can do so. You can, of course, freeze the site again in the future.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick="document.location.hash='#unfreeze-domain-modal'" class="button green-button">Unfreeze Domain</button>
|
<div class="action-buttons-container">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<div class="action-button" v-if="domains[cd].state != 'frozen'">
|
||||||
|
<div class="left">
|
||||||
|
<div class="title">
|
||||||
|
Freeze Domain
|
||||||
</div>
|
</div>
|
||||||
|
<div class="subtitle">
|
||||||
<div class="box" v-if="domains[cd].state != 'frozen'">
|
Freezing your domain will disable new comments and voting temporarily. You may unfreeze the domain later.
|
||||||
<div class="box-subtitle">
|
|
||||||
If you desire to temporarily freeze new comments (domain-wide), thereby making it read-only, you can do so. You can choose to unfreeze later; this is temporary.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="orange-button" onclick="document.location.hash='#freeze-domain-modal'" class="button orange-button">Freeze Domain</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<button onclick="document.location.hash='#freeze-domain-modal'"
|
||||||
|
class="button orange-button">Freeze</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="danger-tab-2" class="content">
|
|
||||||
<div class="box">
|
|
||||||
<div class="box-subtitle">
|
|
||||||
Want to completely remove Commento from your website? This will permanently delete all comments and there is literally no way to retrieve your data once you do this.
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="action-button" v-if="domains[cd].state == 'frozen'">
|
||||||
<button id="big-red-button" class="button big-red-button" onclick="document.location.hash='#delete-domain-modal'">Delete Domain</button>
|
<div class="left">
|
||||||
|
<div class="title">
|
||||||
|
Unfreeze Domain
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">
|
||||||
|
Unfreezing your domain will allow readers to create new comments and vote on comments again. You may re-freeze the domain later.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<button onclick="document.location.hash='#unfreeze-domain-modal'"
|
||||||
|
class="button green-button">Unfreeze</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-button">
|
||||||
|
<div class="left">
|
||||||
|
<div class="title">
|
||||||
|
Clear All Comments
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">
|
||||||
|
This will permanently delete all comments without affecting your settings. This may be useful if you want to clear all comments after testing Commento. Cannot be reversed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<button onclick="document.location.hash='#clear-comments-modal'"
|
||||||
|
class="button big-red-button">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-button">
|
||||||
|
<div class="left">
|
||||||
|
<div class="title">
|
||||||
|
Delete Domain
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">
|
||||||
|
This will permanently delete all comments and all data associated with your domain. There is literally no way to retrieve your data once you do this. Please be certain.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<button onclick="document.location.hash='#delete-domain-modal'"
|
||||||
|
class="button big-red-button">Delete</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,8 +557,8 @@
|
|||||||
<div class="modal-subtitle">
|
<div class="modal-subtitle">
|
||||||
Are you absolutely sure you want to freeze your domain, thereby making it read-only? You can choose to unfreeze later; this is temporary.
|
Are you absolutely sure you want to freeze your domain, thereby making it read-only? You can choose to unfreeze later; this is temporary.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-contents">
|
<div class="modal-contents center">
|
||||||
<button id="orange-button" class="button orange-button" onclick="window.commento.domainFreezeHandler()">Freeze Domain</button>
|
<button class="button orange-button" onclick="window.commento.domainFreezeHandler()">Freeze Domain</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,8 +570,21 @@
|
|||||||
<div class="modal-subtitle">
|
<div class="modal-subtitle">
|
||||||
Are you absolutely sure you want to unfreeze your domain? This will re-allow new comments. You can choose to freeze again in the future.
|
Are you absolutely sure you want to unfreeze your domain? This will re-allow new comments. You can choose to freeze again in the future.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-contents">
|
<div class="modal-contents center">
|
||||||
<button id="blue-button" class="button green-button" onclick="window.commento.domainUnfreezeHandler()">Unfreeze Domain</button>
|
<button class="button green-button" onclick="window.commento.domainUnfreezeHandler()">Unfreeze Domain</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="clear-comments-modal" class="modal-window">
|
||||||
|
<div class="inside">
|
||||||
|
<a href="#modal-close" title="Close" class="modal-close"></a>
|
||||||
|
<div class="modal-title">Clear Comments</div>
|
||||||
|
<div class="modal-subtitle">
|
||||||
|
Are you absolutely sure you want to clear all comments data? This is not reversible, so please be certain.
|
||||||
|
</div>
|
||||||
|
<div class="modal-contents center">
|
||||||
|
<button class="button big-red-button" onclick="window.commento.domainClearHandler()">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,8 +596,8 @@
|
|||||||
<div class="modal-subtitle">
|
<div class="modal-subtitle">
|
||||||
Are you absolutely sure? This will permanently delete all comments and there is literally no way to retrieve your data once you do this.
|
Are you absolutely sure? This will permanently delete all comments and there is literally no way to retrieve your data once you do this.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-contents">
|
<div class="modal-contents center">
|
||||||
<button id="big-red-button" class="button big-red-button" onclick="window.commento.domainDeleteHandler()">Delete Domain</button>
|
<button class="button big-red-button" onclick="window.commento.domainDeleteHandler()">Delete Domain</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
BIN
frontend/fonts/source-sans-300-cyrillic-ext.woff2
Normal file
BIN
frontend/fonts/source-sans-300-cyrillic-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-cyrillic.woff2
Normal file
BIN
frontend/fonts/source-sans-300-cyrillic.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/source-sans-300-greek-ext.woff2
Normal file
BIN
frontend/fonts/source-sans-300-greek-ext.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user